From 7c79c0f431a339af6e62c62b68ccd01e7f3a6c8d Mon Sep 17 00:00:00 2001 From: Elena Stoeva <59341489+ElenaStoeva@users.noreply.github.com> Date: Thu, 15 Feb 2024 13:47:03 +0000 Subject: [PATCH 01/23] [Remote Clusters] Improve Connection mode form fields (#176547) Closes https://github.com/elastic/kibana/issues/162500 ## Summary This PR improves the Connection mode fields of the Remote cluster creation form on Cloud. Screenshot 2024-02-12 at 11 10 27 When we switch the toggle on: Screenshot 2024-02-12 at 11 13 01 **How to test:** These changes apply only for on-Cloud so here are instructions for testing them in a cloud deployment: 1. If you haven't done yet, configure Vault locally (https://docs.elastic.dev/ci/using-secrets#installing-vault). 2. Run `export VAULT_ADDR=https://secrets.elastic.co:8200` 3. Log into Vault: `vault login --method github` 4. Obtain the cloud deployment credentials: `vault read secret/kibana-issues/dev/cloud-deploy/kibana-pr-176547` 5. Access the CI Cloud deployment for this PR: https://kibana-pr-176547.kb.us-west2.gcp.elastic-cloud.com:9243/ 6. Log in with the obtained credentials. 7. Go to Stack Management -> Remote Clusters and start creating a new remote cluster. 8. Try different inputs in the Connection mode fields, click on "Show Request" and verify that the request is correct. 9. Verify that not adding a port to the remote address will result in the `9400` port in the request. 10. Verify that if the `https://` protocol is added to the remote address, it will be stripped in the request. 11. Verify that the Server name by default is equal to the host from the remote address unless you specify a different server name. 13. Verify that a missing remote address or one that has host with invalid characters results in an error message. 14. Start editing a created remote cluster and verify that the form is correctly filled. The self-managed version should not be affected by these changes. Run Es locally with `yarn es snapshot` and Kibana with `yarn start` and verify that the Connection mode fields look and behave in the same way as they do now. --- .../add/remote_clusters_add.test.ts | 45 +++-- .../edit/remote_clusters_edit.test.tsx | 26 ++- .../helpers/remote_clusters_actions.ts | 38 +++- .../components/cloud_url_help.tsx | 61 ------ .../components/connection_mode.tsx | 73 +++---- .../components/connection_mode_cloud.tsx | 175 +++++++++++++++++ .../components/proxy_connection.tsx | 178 +++++++----------- .../remote_cluster_form.tsx | 76 ++++---- .../remote_cluster_form/validators/index.ts | 8 +- .../validators/validate_cloud_url.test.ts | 110 ++++------- .../validators/validate_cloud_url.tsx | 58 +++--- .../validators/validate_cluster.tsx | 17 +- .../validators/validate_server_name.tsx | 22 --- .../translations/translations/fr-FR.json | 13 -- .../translations/translations/ja-JP.json | 13 -- .../translations/translations/zh-CN.json | 13 -- 16 files changed, 434 insertions(+), 492 deletions(-) delete mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode_cloud.tsx delete mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_server_name.tsx diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts index 650d8b50625ab..0a73eddd6e5c7 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts @@ -80,8 +80,8 @@ describe('Create Remote cluster', () => { expect(actions.saveButton.isDisabled()).toBe(true); }); - test('renders no switch for cloud url input and proxy address + server name input modes', () => { - expect(actions.cloudUrlSwitch.exists()).toBe(false); + test('renders no switch for cloud advanced options', () => { + expect(actions.cloudAdvancedOptionsSwitch.exists()).toBe(false); }); }); describe('on cloud', () => { @@ -93,19 +93,21 @@ describe('Create Remote cluster', () => { component.update(); }); - test('renders a switch between cloud url input and proxy address + server name input for proxy connection', () => { - expect(actions.cloudUrlSwitch.exists()).toBe(true); + test('TLS server name has optional label', () => { + actions.cloudAdvancedOptionsSwitch.toggle(); + expect(actions.tlsServerNameInput.getLabel()).toBe('TLS server name (optional)'); + }); + + test('renders a switch for advanced options', () => { + expect(actions.cloudAdvancedOptionsSwitch.exists()).toBe(true); }); test('renders no switch between sniff and proxy modes', () => { expect(actions.connectionModeSwitch.exists()).toBe(false); }); - test('defaults to cloud url input for proxy connection', () => { - expect(actions.cloudUrlSwitch.isChecked()).toBe(false); - }); - test('server name has no optional label', () => { - actions.cloudUrlSwitch.toggle(); - expect(actions.serverNameInput.getLabel()).toBe('Server name'); + + test('advanced options are initially disabled', () => { + expect(actions.cloudAdvancedOptionsSwitch.isChecked()).toBe(false); }); }); }); @@ -290,19 +292,22 @@ describe('Create Remote cluster', () => { component.update(); }); - test('cloud url is required since cloud url input is enabled by default', () => { + test('remote address is required', () => { actions.saveButton.click(); - expect(actions.getErrorMessages()).toContain('A url is required.'); + expect(actions.getErrorMessages()).toContain('A remote address is required.'); }); - test('proxy address and server name are required when cloud url input is disabled', () => { - actions.cloudUrlSwitch.toggle(); - actions.saveButton.click(); - expect(actions.getErrorMessages()).toEqual([ - 'Name is required.', - 'A proxy address is required.', - 'A server name is required.', - ]); + test('should only allow alpha-numeric characters and "-" (dash) in the remote address "host" part', async () => { + await actions.saveButton.click(); // display form errors + + const expectInvalidChar = (char: string) => { + actions.cloudRemoteAddressInput.setValue(`192.16${char}:3000`); + expect(actions.getErrorMessages()).toContain('Remote address is invalid.'); + }; + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] + .filter(notInArray(['-', '_', ':'])) + .forEach(expectInvalidChar); }); }); }); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx index d83e82397b19c..c4c656bb646d5 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx @@ -77,7 +77,7 @@ describe('Edit Remote cluster', () => { describe('on cloud', () => { const cloudUrl = 'cloud-url'; const defaultCloudPort = '9400'; - test('existing cluster that defaults to cloud url (default port)', async () => { + test('existing cluster that has the same TLS server name as the host in the remote address', async () => { const cluster: Cluster = { name: REMOTE_CLUSTER_EDIT_NAME, mode: 'proxy', @@ -92,16 +92,16 @@ describe('Edit Remote cluster', () => { }); component.update(); - expect(actions.cloudUrlInput.exists()).toBe(true); - expect(actions.cloudUrlInput.getValue()).toBe(cloudUrl); + expect(actions.cloudRemoteAddressInput.exists()).toBe(true); + expect(actions.cloudRemoteAddressInput.getValue()).toBe(`${cloudUrl}:${defaultCloudPort}`); + expect(actions.tlsServerNameInput.exists()).toBe(false); }); - test('existing cluster that defaults to manual input (non-default port)', async () => { + test("existing cluster that doesn't have a TLS server name", async () => { const cluster: Cluster = { name: REMOTE_CLUSTER_EDIT_NAME, mode: 'proxy', proxyAddress: `${cloudUrl}:9500`, - serverName: cloudUrl, securityModel: 'certificate', }; httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); @@ -111,13 +111,12 @@ describe('Edit Remote cluster', () => { }); component.update(); - expect(actions.cloudUrlInput.exists()).toBe(false); - - expect(actions.proxyAddressInput.exists()).toBe(true); - expect(actions.serverNameInput.exists()).toBe(true); + expect(actions.cloudRemoteAddressInput.exists()).toBe(true); + expect(actions.cloudRemoteAddressInput.getValue()).toBe(`${cloudUrl}:9500`); + expect(actions.tlsServerNameInput.exists()).toBe(true); }); - test('existing cluster that defaults to manual input (proxy address is different from server name)', async () => { + test('existing cluster that has remote address different from TLS server name)', async () => { const cluster: Cluster = { name: REMOTE_CLUSTER_EDIT_NAME, mode: 'proxy', @@ -132,10 +131,9 @@ describe('Edit Remote cluster', () => { }); component.update(); - expect(actions.cloudUrlInput.exists()).toBe(false); - - expect(actions.proxyAddressInput.exists()).toBe(true); - expect(actions.serverNameInput.exists()).toBe(true); + expect(actions.cloudRemoteAddressInput.exists()).toBe(true); + expect(actions.cloudRemoteAddressInput.getValue()).toBe(`${cloudUrl}:${defaultCloudPort}`); + expect(actions.tlsServerNameInput.exists()).toBe(true); }); }); }); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts index f17a790c65041..f657058231a84 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts @@ -28,14 +28,15 @@ export interface RemoteClustersActions { toggle: () => void; isChecked: () => boolean; }; - cloudUrlSwitch: { + cloudAdvancedOptionsSwitch: { toggle: () => void; exists: () => boolean; isChecked: () => boolean; }; - cloudUrlInput: { + cloudRemoteAddressInput: { exists: () => boolean; getValue: () => string; + setValue: (remoteAddress: string) => void; }; seedsInput: { setValue: (seed: string) => void; @@ -49,6 +50,10 @@ export interface RemoteClustersActions { getLabel: () => string; exists: () => boolean; }; + tlsServerNameInput: { + getLabel: () => string; + exists: () => boolean; + }; isOnFirstStep: () => boolean; saveButton: { click: () => void; @@ -123,10 +128,10 @@ export const createRemoteClustersActions = (testBed: TestBed): RemoteClustersAct }; }; - const createCloudUrlSwitchActions = () => { - const cloudUrlSelector = 'remoteClusterFormCloudUrlToggle'; + const createCloudAdvancedOptionsSwitchActions = () => { + const cloudUrlSelector = 'remoteClusterFormCloudAdvancedOptionsToggle'; return { - cloudUrlSwitch: { + cloudAdvancedOptionsSwitch: { exists: () => exists(cloudUrlSelector), toggle: () => { act(() => { @@ -230,14 +235,26 @@ export const createRemoteClustersActions = (testBed: TestBed): RemoteClustersAct }; }; + const createTlsServerNameActions = () => { + const serverNameSelector = 'remoteClusterFormTLSServerNameFormRow'; + return { + tlsServerNameInput: { + getLabel: () => find(serverNameSelector).find('label').text(), + exists: () => exists(serverNameSelector), + }, + }; + }; + const globalErrorExists = () => exists('remoteClusterFormGlobalError'); - const createCloudUrlInputActions = () => { - const cloudUrlInputSelector = 'remoteClusterFormCloudUrlInput'; + const createCloudRemoteAddressInputActions = () => { + const cloudUrlInputSelector = 'remoteClusterFormRemoteAddressInput'; return { - cloudUrlInput: { + cloudRemoteAddressInput: { exists: () => exists(cloudUrlInputSelector), getValue: () => find(cloudUrlInputSelector).props().value, + setValue: (remoteAddress: string) => + form.setInputValue(cloudUrlInputSelector, remoteAddress), }, }; }; @@ -247,11 +264,12 @@ export const createRemoteClustersActions = (testBed: TestBed): RemoteClustersAct ...createNameInputActions(), ...createSkipUnavailableActions(), ...createConnectionModeActions(), - ...createCloudUrlSwitchActions(), + ...createCloudAdvancedOptionsSwitchActions(), ...createSeedsInputActions(), - ...createCloudUrlInputActions(), + ...createCloudRemoteAddressInputActions(), ...createProxyAddressActions(), ...createServerNameActions(), + ...createTlsServerNameActions(), ...createSetupTrustActions(), getErrorMessages: form.getErrorsMessages, globalErrorExists, diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx deleted file mode 100644 index 504d77514e654..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FunctionComponent, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiLink, EuiPopover, EuiPopoverTitle, EuiText } from '@elastic/eui'; -import { useAppContext } from '../../../../app_context'; - -export const CloudUrlHelp: FunctionComponent = () => { - const [isOpen, setIsOpen] = useState(false); - const { cloudBaseUrl } = useAppContext(); - return ( - - { - setIsOpen(!isOpen); - }} - > - - - - } - isOpen={isOpen} - closePopover={() => setIsOpen(false)} - anchorPosition="upCenter" - > - - - - - - - - ), - elasticsearch: Elasticsearch, - }} - /> - - - ); -}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx index 1808a519ccac6..f44f5ffc0cf9c 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx @@ -8,12 +8,13 @@ import React, { FunctionComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiDescribedFormGroup, EuiTitle, EuiFormRow, EuiSwitch, EuiSpacer } from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiTitle, EuiFormRow, EuiSwitch } from '@elastic/eui'; import { SNIFF_MODE, PROXY_MODE } from '../../../../../../common/constants'; import { useAppContext } from '../../../../app_context'; import { ClusterErrors } from '../validators'; +import { ConnectionModeCloud } from './connection_mode_cloud'; import { SniffConnection } from './sniff_connection'; import { ProxyConnection } from './proxy_connection'; import { FormFields } from '../remote_cluster_form'; @@ -27,10 +28,12 @@ export interface Props { export const ConnectionMode: FunctionComponent = (props) => { const { fields, onFieldsChange } = props; - const { mode, cloudUrlEnabled } = fields; + const { mode } = fields; const { isCloudEnabled } = useAppContext(); - return ( + return isCloudEnabled ? ( + + ) : ( @@ -44,51 +47,27 @@ export const ConnectionMode: FunctionComponent = (props) => { } description={ <> - {isCloudEnabled ? ( - <> - - - - } - checked={!cloudUrlEnabled} - data-test-subj="remoteClusterFormCloudUrlToggle" - onChange={(e) => onFieldsChange({ cloudUrlEnabled: !e.target.checked })} - /> - - - - ) : ( - <> - + + + + } + checked={mode === PROXY_MODE} + data-test-subj="remoteClusterFormConnectionModeToggle" + onChange={(e) => + onFieldsChange({ mode: e.target.checked ? PROXY_MODE : SNIFF_MODE }) + } /> - - - } - checked={mode === PROXY_MODE} - data-test-subj="remoteClusterFormConnectionModeToggle" - onChange={(e) => - onFieldsChange({ mode: e.target.checked ? PROXY_MODE : SNIFF_MODE }) - } - /> - - - )} + + } fullWidth diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode_cloud.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode_cloud.tsx new file mode 100644 index 0000000000000..42dc257b4baf3 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode_cloud.tsx @@ -0,0 +1,175 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiDescribedFormGroup, + EuiTitle, + EuiFormRow, + EuiSwitch, + EuiSpacer, + EuiFieldText, + EuiLink, + EuiFieldNumber, + EuiCode, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { proxySettingsUrl } from '../../../../services/documentation'; + +import { ClusterErrors } from '../validators'; +import { FormFields } from '../remote_cluster_form'; + +export interface Props { + fields: FormFields; + onFieldsChange: (fields: Partial) => void; + fieldsErrors: ClusterErrors; + areErrorsVisible: boolean; +} + +export const ConnectionModeCloud: FunctionComponent = (props) => { + const { fields, fieldsErrors, areErrorsVisible, onFieldsChange } = props; + const { cloudRemoteAddress, serverName, proxySocketConnections, cloudAdvancedOptionsEnabled } = + fields; + const { cloudRemoteAddress: cloudRemoteAddressError } = fieldsErrors; + + return ( + +

+ +

+ + } + description={ + <> + + + + } + checked={cloudAdvancedOptionsEnabled} + data-test-subj="remoteClusterFormCloudAdvancedOptionsToggle" + onChange={(e) => onFieldsChange({ cloudAdvancedOptionsEnabled: e.target.checked })} + /> + + + + } + fullWidth + > + + } + helpText={ + {'9400'}, + }} + /> + } + isInvalid={Boolean(areErrorsVisible && cloudRemoteAddressError)} + error={cloudRemoteAddressError} + fullWidth + > + onFieldsChange({ cloudRemoteAddress: e.target.value })} + isInvalid={Boolean(areErrorsVisible && cloudRemoteAddressError)} + data-test-subj="remoteClusterFormRemoteAddressInput" + fullWidth + /> + + + {cloudAdvancedOptionsEnabled && ( + <> + + } + helpText={ + + + + ), + }} + /> + } + fullWidth + > + onFieldsChange({ serverName: e.target.value })} + fullWidth + /> + + + + } + helpText={ + + } + fullWidth + > + onFieldsChange({ proxySocketConnections: Number(e.target.value) })} + fullWidth + /> + + + )} +
+ ); +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx index 2158b497016da..f322aebee9d7e 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx @@ -9,132 +9,84 @@ import React, { FunctionComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { EuiFieldNumber, EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; -import { useAppContext } from '../../../../app_context'; import { proxySettingsUrl } from '../../../../services/documentation'; import { Props } from './connection_mode'; -import { CloudUrlHelp } from './cloud_url_help'; export const ProxyConnection: FunctionComponent = (props) => { const { fields, fieldsErrors, areErrorsVisible, onFieldsChange } = props; - const { isCloudEnabled } = useAppContext(); - const { proxyAddress, serverName, proxySocketConnections, cloudUrl, cloudUrlEnabled } = fields; - const { - proxyAddress: proxyAddressError, - serverName: serverNameError, - cloudUrl: cloudUrlError, - } = fieldsErrors; + const { proxyAddress, serverName, proxySocketConnections } = fields; + const { proxyAddress: proxyAddressError } = fieldsErrors; return ( <> - {cloudUrlEnabled ? ( - <> - - } - labelAppend={} - isInvalid={Boolean(areErrorsVisible && cloudUrlError)} - error={cloudUrlError} - fullWidth - helpText={ - - } - > - onFieldsChange({ cloudUrl: e.target.value })} - isInvalid={Boolean(areErrorsVisible && cloudUrlError)} - data-test-subj="remoteClusterFormCloudUrlInput" - fullWidth + <> + + } + helpText={ + - - - ) : ( - <> - - } - helpText={ - - } + } + isInvalid={Boolean(areErrorsVisible && proxyAddressError)} + error={proxyAddressError} + fullWidth + > + onFieldsChange({ proxyAddress: e.target.value })} isInvalid={Boolean(areErrorsVisible && proxyAddressError)} - error={proxyAddressError} + data-test-subj="remoteClusterFormProxyAddressInput" fullWidth - > - onFieldsChange({ proxyAddress: e.target.value })} - isInvalid={Boolean(areErrorsVisible && proxyAddressError)} - data-test-subj="remoteClusterFormProxyAddressInput" - fullWidth - /> - + /> + - - ) : ( - - ) - } - helpText={ - - - - ), - }} - /> - } - fullWidth - > - onFieldsChange({ serverName: e.target.value })} - isInvalid={Boolean(areErrorsVisible && serverNameError)} - fullWidth + - - - )} + } + helpText={ + + + + ), + }} + /> + } + fullWidth + > + onFieldsChange({ serverName: e.target.value })} + fullWidth + /> + + + { { ...defaultClusterValues, mode: defaultMode, - cloudUrl: convertProxyConnectionToCloudUrl(cluster), - cloudUrlEnabled: isCloudEnabled && isCloudUrlEnabled(cluster), + cloudRemoteAddress: cluster?.proxyAddress || '', + cloudAdvancedOptionsEnabled: isCloudAdvancedOptionsEnabled(cluster), }, cluster ); @@ -123,14 +125,33 @@ export class RemoteClusterForm extends Component { onFieldsChange = (changedFields: Partial) => { const { isCloudEnabled } = this.context; - // when cloudUrl changes, fill proxy address and server name - const { cloudUrl } = changedFields; - if (cloudUrl) { - const { proxyAddress, serverName } = convertCloudUrlToProxyConnection(cloudUrl); + // when cloud remote address changes, fill proxy address and server name + const { cloudRemoteAddress, cloudAdvancedOptionsEnabled } = changedFields; + if (cloudRemoteAddress) { + const { proxyAddress, serverName } = + convertCloudRemoteAddressToProxyConnection(cloudRemoteAddress); + // Only change the server name if the advanced options are not currently open + if (this.state.fields.cloudAdvancedOptionsEnabled) { + changedFields = { + ...changedFields, + proxyAddress, + }; + } else { + changedFields = { + ...changedFields, + proxyAddress, + serverName, + }; + } + } + + // If we switch off the advanced options, revert the server name to + // the host name from the proxy address + if (cloudAdvancedOptionsEnabled === false) { changedFields = { ...changedFields, - proxyAddress, - serverName, + serverName: this.state.fields.proxyAddress?.split(':')[0], + proxySocketConnections: defaultClusterValues.proxySocketConnections, }; } @@ -416,13 +437,7 @@ export class RemoteClusterForm extends Component { renderErrors = () => { const { areErrorsVisible, - fieldsErrors: { - name: errorClusterName, - seeds: errorsSeeds, - proxyAddress: errorProxyAddress, - serverName: errorServerName, - cloudUrl: errorCloudUrl, - }, + fieldsErrors: { name: errorClusterName, seeds: errorsSeeds, proxyAddress: errorProxyAddress }, } = this.state; const hasErrors = this.hasErrors(); @@ -463,29 +478,6 @@ export class RemoteClusterForm extends Component { }); } - if (errorServerName) { - errorExplanations.push({ - key: 'serverNameExplanation', - field: i18n.translate( - 'xpack.remoteClusters.remoteClusterForm.inputServerNameErrorMessage', - { - defaultMessage: 'The "Server name" field is invalid.', - } - ), - error: errorServerName, - }); - } - - if (errorCloudUrl) { - errorExplanations.push({ - key: 'cloudUrlExplanation', - field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputcloudUrlErrorMessage', { - defaultMessage: 'The "Elasticsearch endpoint URL" field is invalid.', - }), - error: errorCloudUrl, - }); - } - const messagesToBeRendered = errorExplanations.length && (
diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts index a695b95b8de85..6a710dae744ba 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts @@ -9,12 +9,10 @@ export { validateName } from './validate_name'; export { validateProxy } from './validate_proxy'; export { validateSeeds } from './validate_seeds'; export { validateSeed } from './validate_seed'; -export { validateServerName } from './validate_server_name'; export type { ClusterErrors } from './validate_cluster'; export { validateCluster } from './validate_cluster'; export { - isCloudUrlEnabled, - validateCloudUrl, - convertProxyConnectionToCloudUrl, - convertCloudUrlToProxyConnection, + isCloudAdvancedOptionsEnabled, + validateCloudRemoteAddress, + convertCloudRemoteAddressToProxyConnection, } from './validate_cloud_url'; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts index 5990b29474da2..5bdfd23f5cfdb 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts @@ -6,135 +6,93 @@ */ import { - isCloudUrlEnabled, - validateCloudUrl, - convertCloudUrlToProxyConnection, - convertProxyConnectionToCloudUrl, + isCloudAdvancedOptionsEnabled, + validateCloudRemoteAddress, + convertCloudRemoteAddressToProxyConnection, i18nTexts, } from './validate_cloud_url'; -describe('Cloud url', () => { +describe('Cloud remote address', () => { describe('validation', () => { it('errors when the url is empty', () => { - const actual = validateCloudUrl(''); + const actual = validateCloudRemoteAddress(''); expect(actual).toBe(i18nTexts.urlEmpty); }); it('errors when the url is invalid', () => { - const actual = validateCloudUrl('invalid%url'); + const actual = validateCloudRemoteAddress('invalid%url'); expect(actual).toBe(i18nTexts.urlInvalid); }); }); - describe('is cloud url', () => { - it('true for a new cluster', () => { - const actual = isCloudUrlEnabled(); - expect(actual).toBe(true); + describe('is advanced options toggle enabled', () => { + it('false for a new cluster', () => { + const actual = isCloudAdvancedOptionsEnabled(); + expect(actual).toBe(false); }); - it('true when proxy connection is empty', () => { - const actual = isCloudUrlEnabled({ + it('false when proxy address is empty', () => { + const actual = isCloudAdvancedOptionsEnabled({ name: 'test', proxyAddress: '', - serverName: '', securityModel: 'certificate', }); - expect(actual).toBe(true); + expect(actual).toBe(false); }); - it('true when proxy address is the same as server name and default port', () => { - const actual = isCloudUrlEnabled({ + it('false when proxy address is the same as server name', () => { + const actual = isCloudAdvancedOptionsEnabled({ name: 'test', proxyAddress: 'some-proxy:9400', serverName: 'some-proxy', securityModel: 'certificate', }); - expect(actual).toBe(true); + expect(actual).toBe(false); }); - it('false when proxy address is the same as server name but not default port', () => { - const actual = isCloudUrlEnabled({ + it('true when proxy address is not the same as server name', () => { + const actual = isCloudAdvancedOptionsEnabled({ name: 'test', - proxyAddress: 'some-proxy:1234', - serverName: 'some-proxy', + proxyAddress: 'some-proxy:9400', + serverName: 'some-server-name', securityModel: 'certificate', }); - expect(actual).toBe(false); + expect(actual).toBe(true); }); - it('true when proxy address is not the same as server name', () => { - const actual = isCloudUrlEnabled({ + it('true when socket connections is not the default value', () => { + const actual = isCloudAdvancedOptionsEnabled({ name: 'test', proxyAddress: 'some-proxy:9400', - serverName: 'some-server-name', + serverName: 'some-proxy-name', + proxySocketConnections: 19, securityModel: 'certificate', }); - expect(actual).toBe(false); + expect(actual).toBe(true); }); }); - describe('conversion from cloud url', () => { + describe('conversion from cloud remote address', () => { it('empty url to empty proxy connection values', () => { - const actual = convertCloudUrlToProxyConnection(''); + const actual = convertCloudRemoteAddressToProxyConnection(''); expect(actual).toEqual({ proxyAddress: '', serverName: '' }); }); it('url with protocol and port to proxy connection values', () => { - const actual = convertCloudUrlToProxyConnection('http://test.com:1234'); - expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + const actual = convertCloudRemoteAddressToProxyConnection('http://test.com:1234'); + expect(actual).toEqual({ proxyAddress: 'test.com:1234', serverName: 'test.com' }); }); it('url with protocol and no port to proxy connection values', () => { - const actual = convertCloudUrlToProxyConnection('http://test.com'); + const actual = convertCloudRemoteAddressToProxyConnection('http://test.com'); expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); }); it('url with no protocol to proxy connection values', () => { - const actual = convertCloudUrlToProxyConnection('test.com'); + const actual = convertCloudRemoteAddressToProxyConnection('test.com'); expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); }); + it('invalid url to empty proxy connection values', () => { - const actual = convertCloudUrlToProxyConnection('invalid%url'); + const actual = convertCloudRemoteAddressToProxyConnection('invalid%url'); expect(actual).toEqual({ proxyAddress: '', serverName: '' }); }); }); - - describe('conversion to cloud url', () => { - it('empty proxy address to empty cloud url', () => { - const actual = convertProxyConnectionToCloudUrl({ - name: 'test', - proxyAddress: '', - serverName: 'test', - securityModel: 'certificate', - }); - expect(actual).toEqual(''); - }); - - it('empty server name to empty cloud url', () => { - const actual = convertProxyConnectionToCloudUrl({ - name: 'test', - proxyAddress: 'test', - serverName: '', - securityModel: 'certificate', - }); - expect(actual).toEqual(''); - }); - - it('different proxy address and server name to empty cloud url', () => { - const actual = convertProxyConnectionToCloudUrl({ - name: 'test', - proxyAddress: 'test', - serverName: 'another-test', - securityModel: 'certificate', - }); - expect(actual).toEqual(''); - }); - - it('valid proxy connection to cloud url', () => { - const actual = convertProxyConnectionToCloudUrl({ - name: 'test', - proxyAddress: 'test-proxy:9400', - serverName: 'test-proxy', - securityModel: 'certificate', - }); - expect(actual).toEqual('test-proxy'); - }); - }); }); diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx index deb0148f36edf..54f050a789b9f 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx @@ -13,14 +13,14 @@ import { isAddressValid } from './validate_address'; export const i18nTexts = { urlEmpty: ( ), urlInvalid: ( ), }; @@ -28,20 +28,22 @@ export const i18nTexts = { const CLOUD_DEFAULT_PROXY_PORT = '9400'; const EMPTY_PROXY_VALUES = { proxyAddress: '', serverName: '' }; const PROTOCOL_REGEX = new RegExp(/^https?:\/\//); +const DEFAULT_SOCKET_CONNECTIONS = 18; -export const isCloudUrlEnabled = (cluster?: Cluster): boolean => { - // enable cloud url for new clusters +export const isCloudAdvancedOptionsEnabled = (cluster?: Cluster): boolean => { + // The toggle is switched off by default if (!cluster) { - return true; + return false; } - const { proxyAddress, serverName } = cluster; - if (!proxyAddress && !serverName) { - return true; + const { proxyAddress, serverName, proxySocketConnections } = cluster; + if (!proxyAddress) { + return false; } - const portParts = (proxyAddress ?? '').split(':'); - const proxyAddressWithoutPort = portParts[0]; - const port = portParts[1]; - return port === CLOUD_DEFAULT_PROXY_PORT && proxyAddressWithoutPort === serverName; + const proxyAddressWithoutPort = (proxyAddress ?? '').split(':')[0]; + return ( + proxyAddressWithoutPort !== serverName || + (proxySocketConnections != null && proxySocketConnections !== DEFAULT_SOCKET_CONNECTIONS) + ); }; const formatUrl = (url: string) => { @@ -51,29 +53,23 @@ const formatUrl = (url: string) => { return url; }; -export const convertProxyConnectionToCloudUrl = (cluster?: Cluster): string => { - if (!isCloudUrlEnabled(cluster)) { - return ''; - } - return cluster?.serverName ?? ''; -}; -export const convertCloudUrlToProxyConnection = ( - cloudUrl: string = '' -): { proxyAddress: string; serverName: string } => { - cloudUrl = formatUrl(cloudUrl); - if (!cloudUrl || !isAddressValid(cloudUrl)) { +export const convertCloudRemoteAddressToProxyConnection = (url: string) => { + url = formatUrl(url); + if (!url || !isAddressValid(url)) { return EMPTY_PROXY_VALUES; } - const address = cloudUrl.split(':')[0]; - return { proxyAddress: `${address}:${CLOUD_DEFAULT_PROXY_PORT}`, serverName: address }; + const host = url.split(':')[0]; + const port = url.split(':')[1]; + const proxyAddress = port ? url : `${host}:${CLOUD_DEFAULT_PROXY_PORT}`; + return { proxyAddress, serverName: host }; }; -export const validateCloudUrl = (cloudUrl: string): JSX.Element | null => { - if (!cloudUrl) { +export const validateCloudRemoteAddress = (url?: string): JSX.Element | null => { + if (!url) { return i18nTexts.urlEmpty; } - cloudUrl = formatUrl(cloudUrl); - if (!isAddressValid(cloudUrl)) { + url = formatUrl(url); + if (!isAddressValid(url)) { return i18nTexts.urlInvalid; } return null; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx index e0fa434f21d5c..aba0b0462cdf5 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx @@ -9,8 +9,7 @@ import { validateName } from './validate_name'; import { PROXY_MODE, SNIFF_MODE } from '../../../../../../common/constants'; import { validateSeeds } from './validate_seeds'; import { validateProxy } from './validate_proxy'; -import { validateServerName } from './validate_server_name'; -import { validateCloudUrl } from './validate_cloud_url'; +import { validateCloudRemoteAddress } from './validate_cloud_url'; import { FormFields } from '../remote_cluster_form'; type ClusterError = JSX.Element | null; @@ -19,21 +18,15 @@ export interface ClusterErrors { name?: ClusterError; seeds?: ClusterError; proxyAddress?: ClusterError; - serverName?: ClusterError; - cloudUrl?: ClusterError; + cloudRemoteAddress?: ClusterError; } export const validateCluster = (fields: FormFields, isCloudEnabled: boolean): ClusterErrors => { - const { name, seeds = [], mode, proxyAddress, serverName, cloudUrlEnabled, cloudUrl } = fields; + const { name, seeds = [], mode, proxyAddress, cloudRemoteAddress } = fields; return { name: validateName(name), seeds: mode === SNIFF_MODE ? validateSeeds(seeds) : null, - proxyAddress: !cloudUrlEnabled && mode === PROXY_MODE ? validateProxy(proxyAddress) : null, - // server name is only required in cloud when proxy mode is enabled - serverName: - !cloudUrlEnabled && isCloudEnabled && mode === PROXY_MODE - ? validateServerName(serverName) - : null, - cloudUrl: cloudUrlEnabled ? validateCloudUrl(cloudUrl) : null, + proxyAddress: mode === PROXY_MODE ? validateProxy(proxyAddress) : null, + cloudRemoteAddress: isCloudEnabled ? validateCloudRemoteAddress(cloudRemoteAddress) : null, }; }; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_server_name.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_server_name.tsx deleted file mode 100644 index eb110f75e9e15..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_server_name.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; - -export function validateServerName(serverName?: string) { - if (!serverName || !serverName.trim()) { - return ( - - ); - } - - return null; -} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index c0b43ad512382..05aff9e3894de 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -29666,7 +29666,6 @@ "xpack.remoteClusters.detailPanel.deprecatedSettingsMessage": "{editLink} pour mettre à jour les paramètres.", "xpack.remoteClusters.editAction.failedDefaultErrorMessage": "La requête a échoué avec une erreur {statusCode}. {message}", "xpack.remoteClusters.form.errors.illegalCharacters": "Supprimez {characterListLength, plural, one {le caractère} many {les caractères} other {les caractères}} {characterList} du nom.", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.stepOneText": "Ouvrez le {deploymentsLink}, sélectionnez le déploiement distant et copiez l'URL de point de terminaison {elasticsearch}.", "xpack.remoteClusters.remoteClusterForm.fieldSeedsHelpText": "Adresse IP ou nom d'hôte, suivi du {transportPort} du cluster distant. Spécifiez les différents nœuds initiaux afin que la découverte n'échoue pas si un nœud n'est pas disponible.", "xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText": "Chaîne envoyée dans le champ server_name de l'extension d'indication de nom du serveur TLS si TLS est activé. {learnMoreLink}", "xpack.remoteClusters.remoteClusterForm.nextButtonLabel": "{isEditMode, select, true {Enregistrer} other {Suivant}}", @@ -29684,8 +29683,6 @@ "xpack.remoteClusters.addTitle": "Ajouter un cluster distant", "xpack.remoteClusters.appName": "Clusters distants", "xpack.remoteClusters.appTitle": "Clusters distants", - "xpack.remoteClusters.cloudDeploymentForm.urlInvalidError": "L'URL n'est pas valide", - "xpack.remoteClusters.cloudDeploymentForm.urlRequiredError": "Une URL est requise.", "xpack.remoteClusters.clusterWizard.addConnectionInfoLabel": "Ajouter des informations de connexion", "xpack.remoteClusters.clusterWizard.setupTrustLabel": "Établir la confiance", "xpack.remoteClusters.clusterWizard.trustStep.backButtonLabel": "Retour", @@ -29749,19 +29746,13 @@ "xpack.remoteClusters.form.errors.illegalSpace": "Les espaces ne sont pas autorisés dans le nom.", "xpack.remoteClusters.form.errors.nameMissing": "Le nom est requis.", "xpack.remoteClusters.form.errors.seedMissing": "Au moins un nœud initial est requis.", - "xpack.remoteClusters.form.errors.serverNameMissing": "Un nom de serveur est requis.", "xpack.remoteClusters.licenseCheckErrorMessage": "La vérification de la licence a échoué", "xpack.remoteClusters.listBreadcrumbTitle": "Clusters distants", "xpack.remoteClusters.readDocsButtonLabel": "Documents du cluster distant", "xpack.remoteClusters.refreshAction.errorTitle": "Erreur lors de l'actualisation des clusters distants", "xpack.remoteClusters.remoteClusterForm.addressError.invalidPortMessage": "Un port est requis.", "xpack.remoteClusters.remoteClusterForm.cancelButtonLabel": "Annuler", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.buttonLabel": "Besoin d'aide ?", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.popoverTitle": "Comment trouver votre URL de point de terminaison Elasticsearch", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelpModal.deploymentsLink": "page des déploiements", "xpack.remoteClusters.remoteClusterForm.errorTitle": "Il vous faut inspecter certains champs.", - "xpack.remoteClusters.remoteClusterForm.fieldCloudUrlHelpText": "Les valeurs de protocole (https://) et de port sont facultatives.", - "xpack.remoteClusters.remoteClusterForm.fieldCloudUrlLabel": "URL de point de terminaison Elasticsearch", "xpack.remoteClusters.remoteClusterForm.fieldModeLabel": "Utiliser le mode proxy", "xpack.remoteClusters.remoteClusterForm.fieldNameLabel": "Nom", "xpack.remoteClusters.remoteClusterForm.fieldNameLabelHelpText": "Doit contenir uniquement des lettres, chiffres, traits de soulignement et tirets.", @@ -29775,18 +29766,14 @@ "xpack.remoteClusters.remoteClusterForm.fieldSeedsPlaceholder": "host:port", "xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText.learnMoreLinkLabel": "En savoir plus.", "xpack.remoteClusters.remoteClusterForm.fieldServerNameOptionalLabel": "Nom du serveur (facultatif)", - "xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "Nom du serveur", "xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "Nombre de connexions à ouvrir par cluster distant.", "xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "Masquer la requête", - "xpack.remoteClusters.remoteClusterForm.inputcloudUrlErrorMessage": "Le champ \"URL de point de terminaison Elasticsearch\" n'est pas valide.", "xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "Le champ \"Nom\" n'est pas valide.", "xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "Le champ \"Adresse proxy\" n'est pas valide.", "xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "Le champ \"Nœuds initiaux\" n'est pas valide.", - "xpack.remoteClusters.remoteClusterForm.inputServerNameErrorMessage": "Le champ \"Nom du serveur\" n'est pas valide.", "xpack.remoteClusters.remoteClusterForm.localSeedError.duplicateMessage": "Les nœuds initiaux en double ne sont pas autorisés.\"", "xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage": "Le nœud initial doit utiliser le format host:port. Exemple : 127.0.0.1:9400, localhost:9400. Les hôtes ne peuvent comprendre que des lettres, des chiffres et des tirets.", "xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage": "Un port est requis.", - "xpack.remoteClusters.remoteClusterForm.manualModeFieldLabel": "Entrer manuellement l'adresse proxy et le nom du serveur", "xpack.remoteClusters.remoteClusterForm.proxyError.invalidCharactersMessage": "L'adresse doit utiliser le format host:port. Exemple : 127.0.0.1:9400, localhost:9400. Les hôtes ne peuvent comprendre que des lettres, des chiffres et des tirets.", "xpack.remoteClusters.remoteClusterForm.proxyError.missingProxyMessage": "Une adresse proxy est requise.", "xpack.remoteClusters.remoteClusterForm.sectionModeCloudDescription": "Configurez automatiquement le cluster distant à l'aide de l'URL de point de terminaison Elasticsearch du déploiement distant ou entrez l'adresse proxy et le nom du serveur manuellement.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index de53645abf2c9..ae652805fe475 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -29667,7 +29667,6 @@ "xpack.remoteClusters.detailPanel.deprecatedSettingsMessage": "設定を更新するための{editLink}。", "xpack.remoteClusters.editAction.failedDefaultErrorMessage": "{statusCode}エラーでリクエストが失敗しました。{message}", "xpack.remoteClusters.form.errors.illegalCharacters": "名前から{characterListLength, plural, other {文字}}{characterList}を削除します。", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.stepOneText": "{deploymentsLink}を開き、リモート配置を選択し、{elasticsearch}のエンドポイントURLをコピーします。", "xpack.remoteClusters.remoteClusterForm.fieldSeedsHelpText": "リモートクラスターの{transportPort}の前にくるIPアドレスまたはホスト名です。ノードが利用不能な場合に発見が失敗しないように複数のシードノードを指定します。", "xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText": "TLSが有効な場合にTLSサーバー名表示拡張のserver_nameフィールドで送信される文字列。{learnMoreLink}", "xpack.remoteClusters.remoteClusterForm.nextButtonLabel": "{isEditMode, select, true {保存} other {次へ}}", @@ -29685,8 +29684,6 @@ "xpack.remoteClusters.addTitle": "リモートクラスターを追加", "xpack.remoteClusters.appName": "リモートクラスター", "xpack.remoteClusters.appTitle": "リモートクラスター", - "xpack.remoteClusters.cloudDeploymentForm.urlInvalidError": "URLが無効です", - "xpack.remoteClusters.cloudDeploymentForm.urlRequiredError": "URLは必須です。", "xpack.remoteClusters.clusterWizard.addConnectionInfoLabel": "接続情報を追加", "xpack.remoteClusters.clusterWizard.setupTrustLabel": "信頼を確立", "xpack.remoteClusters.clusterWizard.trustStep.backButtonLabel": "戻る", @@ -29750,19 +29747,13 @@ "xpack.remoteClusters.form.errors.illegalSpace": "名前にスペースは使用できません。", "xpack.remoteClusters.form.errors.nameMissing": "名前が必要です。", "xpack.remoteClusters.form.errors.seedMissing": "シードノードが最低 1 つ必要です。", - "xpack.remoteClusters.form.errors.serverNameMissing": "サーバー名が必要です。", "xpack.remoteClusters.licenseCheckErrorMessage": "ライセンス確認失敗", "xpack.remoteClusters.listBreadcrumbTitle": "リモートクラスター", "xpack.remoteClusters.readDocsButtonLabel": "リモートクラスタードキュメント", "xpack.remoteClusters.refreshAction.errorTitle": "リモートクラスターの更新中にエラーが発生", "xpack.remoteClusters.remoteClusterForm.addressError.invalidPortMessage": "ポートが必要です。", "xpack.remoteClusters.remoteClusterForm.cancelButtonLabel": "キャンセル", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.buttonLabel": "ヘルプが必要な場合", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.popoverTitle": "ElasticsearchエンドポイントURLを見つける方法", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelpModal.deploymentsLink": "デプロイページ", "xpack.remoteClusters.remoteClusterForm.errorTitle": "一部のフィールドでは注意が必要です。", - "xpack.remoteClusters.remoteClusterForm.fieldCloudUrlHelpText": "プロトコル(https://)とポート値は任意です。", - "xpack.remoteClusters.remoteClusterForm.fieldCloudUrlLabel": "ElasticsearchエンドポイントURL", "xpack.remoteClusters.remoteClusterForm.fieldModeLabel": "プロキシモードを使用", "xpack.remoteClusters.remoteClusterForm.fieldNameLabel": "名前", "xpack.remoteClusters.remoteClusterForm.fieldNameLabelHelpText": "文字、数字、アンダースコア、ハイフンのみ使用できます。", @@ -29776,18 +29767,14 @@ "xpack.remoteClusters.remoteClusterForm.fieldSeedsPlaceholder": "ホスト:ポート", "xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText.learnMoreLinkLabel": "詳細情報", "xpack.remoteClusters.remoteClusterForm.fieldServerNameOptionalLabel": "サーバー名(任意)", - "xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "サーバー名", "xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "リモートクラスターごとに開く接続の数。", "xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "リクエストを非表示", - "xpack.remoteClusters.remoteClusterForm.inputcloudUrlErrorMessage": "「ElasticsearchエンドポイントURL」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "「名前」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "「プロキシアドレス」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "「シードノード」フィールドが無効です。", - "xpack.remoteClusters.remoteClusterForm.inputServerNameErrorMessage": "「サーバー名」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.localSeedError.duplicateMessage": "重複シードノードは使用できません。`", "xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage": "シードノードはホストポートのフォーマットを使用する必要があります。例:127.0.0.1:9400、localhost:9400ホストには文字、数字、ハイフンのみが使用できます。", "xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage": "ポートが必要です。", - "xpack.remoteClusters.remoteClusterForm.manualModeFieldLabel": "手動でプロキシアドレスとサーバー名を入力", "xpack.remoteClusters.remoteClusterForm.proxyError.invalidCharactersMessage": "アドレスはホスト:ポートの形式にする必要があります。例:127.0.0.1:9400、localhost:9400ホストには文字、数字、ハイフンのみが使用できます。", "xpack.remoteClusters.remoteClusterForm.proxyError.missingProxyMessage": "プロキシアドレスが必要です。", "xpack.remoteClusters.remoteClusterForm.sectionModeCloudDescription": "リモートデプロイのElasticsearchエンドポイントURLを使用して、リモートクラスターを自動的に構成するか、プロキシアドレスとサーバー名を手動で入力します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d155530f9d158..f51c0039ff8c9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -29651,7 +29651,6 @@ "xpack.remoteClusters.detailPanel.deprecatedSettingsMessage": "{editLink}以更新设置。", "xpack.remoteClusters.editAction.failedDefaultErrorMessage": "请求失败,显示 {statusCode} 错误。{message}", "xpack.remoteClusters.form.errors.illegalCharacters": "从名称中删除{characterListLength, plural, other {字符}} {characterList}。", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.stepOneText": "打开 {deploymentsLink},选择远程部署并复制 {elasticsearch} 终端 URL。", "xpack.remoteClusters.remoteClusterForm.fieldSeedsHelpText": "IP 地址或主机名,后跟远程集群的 {transportPort}。指定多个种子节点,以便在节点不可用时发现不会失败。", "xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText": "启用 TLS 时在 TLS 服务器名称指示扩展的 server_name 字段中发送的字符串。{learnMoreLink}", "xpack.remoteClusters.remoteClusterForm.nextButtonLabel": "{isEditMode, select, true {保存} other {下一步}}", @@ -29669,8 +29668,6 @@ "xpack.remoteClusters.addTitle": "添加远程集群", "xpack.remoteClusters.appName": "远程集群", "xpack.remoteClusters.appTitle": "远程集群", - "xpack.remoteClusters.cloudDeploymentForm.urlInvalidError": "URL 无效", - "xpack.remoteClusters.cloudDeploymentForm.urlRequiredError": "URL 必填。", "xpack.remoteClusters.clusterWizard.addConnectionInfoLabel": "添加连接信息", "xpack.remoteClusters.clusterWizard.setupTrustLabel": "建立信任", "xpack.remoteClusters.clusterWizard.trustStep.backButtonLabel": "返回", @@ -29734,19 +29731,13 @@ "xpack.remoteClusters.form.errors.illegalSpace": "名称中不允许使用空格。", "xpack.remoteClusters.form.errors.nameMissing": "“名称”必填。", "xpack.remoteClusters.form.errors.seedMissing": "至少需要一个种子节点。", - "xpack.remoteClusters.form.errors.serverNameMissing": "服务器名必填。", "xpack.remoteClusters.licenseCheckErrorMessage": "许可证检查失败", "xpack.remoteClusters.listBreadcrumbTitle": "远程集群", "xpack.remoteClusters.readDocsButtonLabel": "远程集群文档", "xpack.remoteClusters.refreshAction.errorTitle": "刷新远程集群时出错", "xpack.remoteClusters.remoteClusterForm.addressError.invalidPortMessage": "端口必填。", "xpack.remoteClusters.remoteClusterForm.cancelButtonLabel": "取消", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.buttonLabel": "需要帮助?", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.popoverTitle": "如何查找 Elasticsearch 终端 URL", - "xpack.remoteClusters.remoteClusterForm.cloudUrlHelpModal.deploymentsLink": "部署页面", "xpack.remoteClusters.remoteClusterForm.errorTitle": "某些字段需要您的关注。", - "xpack.remoteClusters.remoteClusterForm.fieldCloudUrlHelpText": "协议 (https://) 和端口值可选。", - "xpack.remoteClusters.remoteClusterForm.fieldCloudUrlLabel": "Elasticsearch 终端 URL", "xpack.remoteClusters.remoteClusterForm.fieldModeLabel": "使用代理模式", "xpack.remoteClusters.remoteClusterForm.fieldNameLabel": "名称", "xpack.remoteClusters.remoteClusterForm.fieldNameLabelHelpText": "只能包含字母、数字、下划线和短划线。", @@ -29760,18 +29751,14 @@ "xpack.remoteClusters.remoteClusterForm.fieldSeedsPlaceholder": "host:port", "xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText.learnMoreLinkLabel": "了解详情。", "xpack.remoteClusters.remoteClusterForm.fieldServerNameOptionalLabel": "服务器名(可选)", - "xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "服务器名", "xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "每个远程集群要打开的连接数目。", "xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "隐藏请求", - "xpack.remoteClusters.remoteClusterForm.inputcloudUrlErrorMessage": "“Elasticsearch 终端 URL”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "“名称”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "“代理地址”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "“种子节点”字段无效。", - "xpack.remoteClusters.remoteClusterForm.inputServerNameErrorMessage": "“服务器名称”字段无效。", "xpack.remoteClusters.remoteClusterForm.localSeedError.duplicateMessage": "不允许重复的种子节点。`", "xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage": "种子节点必须使用 host:port 格式。例如:127.0.0.1:9400、localhost:9400。主机只能由字母、数字和短划线构成。", "xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage": "端口必填。", - "xpack.remoteClusters.remoteClusterForm.manualModeFieldLabel": "手动输入代理地址和服务器名称", "xpack.remoteClusters.remoteClusterForm.proxyError.invalidCharactersMessage": "地址必须使用 host:port 格式。例如:127.0.0.1:9400、localhost:9400。主机只能由字母、数字和短划线构成。", "xpack.remoteClusters.remoteClusterForm.proxyError.missingProxyMessage": "必须指定代理地址。", "xpack.remoteClusters.remoteClusterForm.sectionModeCloudDescription": "通过使用远程部署的 Elasticsearch 终端 URL 自动配置运程集群或手动输入代理地址和服务器名称。", From 061d9829ad71b9f93b0745987fbd4728fa816ce1 Mon Sep 17 00:00:00 2001 From: Konrad Szwarc Date: Thu, 15 Feb 2024 15:28:42 +0100 Subject: [PATCH 02/23] [EDR Workflows] Make saving interactions more intuitive within endpoint integration policy (#176605) This PR: 1. Makes the save button on the Policy Settings tab disabled when you first open it. The button will only become enabled if you make changes. 2. It adds a popup that warns you about unsaved changes whenever you try to switch tabs while there are unsaved changes on the current tab. Added to both Policy Settings and Protection Updates tabs. https://github.com/elastic/kibana/assets/29123534/ccabafc9-6b11-4a25-805b-241edbc5d1e1 ![Screenshot 2024-02-13 at 14 06 48](https://github.com/elastic/kibana/assets/29123534/054c0c63-b409-4bc0-a73d-2a4b4fe6876d) --- .../cypress/e2e/policy/policy_details.cy.ts | 62 +++- .../policy_settings_layout.test.tsx | 19 +- .../policy_settings_layout.tsx | 340 +++++++++--------- .../protection_updates_layout.tsx | 7 +- .../pages/policy/view/tabs/policy_tabs.tsx | 100 +++++- .../tabs/unsaved_changes_confirm_modal.tsx | 46 +++ 6 files changed, 395 insertions(+), 179 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/unsaved_changes_confirm_modal.tsx diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/policy/policy_details.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/policy/policy_details.cy.ts index 78239fbca02d1..0bc6e907fe827 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/policy/policy_details.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/policy/policy_details.cy.ts @@ -44,9 +44,7 @@ describe( const testNote = 'test note'; const updatedTestNote = 'updated test note'; - // FLAKY: https://github.com/elastic/kibana/issues/169187 - // FLAKY: https://github.com/elastic/kibana/issues/169188 - describe.skip('Renders and saves protection updates', () => { + describe('Renders and saves protection updates', () => { let indexedPolicy: IndexedFleetEndpointPolicyResponse; let policy: PolicyData; const defaultDate = moment.utc().subtract(1, 'days'); @@ -90,6 +88,21 @@ describe( cy.getByTestSubj('protectionUpdatesSaveButton').should('be.enabled'); }); + it('should display warning modal when user has unsaved changes', () => { + loadProtectionUpdatesUrl(policy.id); + cy.getByTestSubj('protection-updates-manifest-switch').click(); + cy.getByTestSubj('policySettingsTab').click(); + cy.getByTestSubj('policyDetailsUnsavedChangesModal').within(() => { + cy.getByTestSubj('confirmModalCancelButton').click(); + }); + cy.url().should('include', 'protectionUpdates'); + cy.getByTestSubj('policySettingsTab').click(); + cy.getByTestSubj('policyDetailsUnsavedChangesModal').within(() => { + cy.getByTestSubj('confirmModalConfirmButton').click(); + }); + cy.url().should('include', 'settings'); + }); + it('should successfully update the manifest version to custom date', () => { loadProtectionUpdatesUrl(policy.id); cy.getByTestSubj('protectionUpdatesSaveButton').should('be.disabled'); @@ -310,5 +323,48 @@ describe( }); }); }); + describe('Policy settings', () => { + const loadSettingsUrl = (policyId: string) => + loadPage(`/app/security/administration/policy/${policyId}/settings`); + + describe('Renders policy settings form', () => { + let indexedPolicy: IndexedFleetEndpointPolicyResponse; + let policy: PolicyData; + + beforeEach(() => { + login(); + disableExpandableFlyoutAdvancedSettings(); + getEndpointIntegrationVersion().then((version) => { + createAgentPolicyTask(version).then((data) => { + indexedPolicy = data; + policy = indexedPolicy.integrationPolicies[0]; + }); + }); + }); + + afterEach(() => { + if (indexedPolicy) { + cy.task('deleteIndexedFleetEndpointPolicies', indexedPolicy); + } + }); + it('should render disabled button and display modal if unsaved changes are present', () => { + loadSettingsUrl(policy.id); + cy.getByTestSubj('policyDetailsSaveButton').should('be.disabled'); + cy.getByTestSubj('endpointPolicyForm-malware-enableDisableSwitch').click(); + cy.getByTestSubj('policyDetailsSaveButton').should('not.be.disabled'); + cy.getByTestSubj('policyProtectionUpdatesTab').click(); + cy.getByTestSubj('policyDetailsUnsavedChangesModal').within(() => { + cy.getByTestSubj('confirmModalCancelButton').click(); + }); + cy.url().should('include', 'settings'); + cy.getByTestSubj('policyProtectionUpdatesTab').click(); + + cy.getByTestSubj('policyDetailsUnsavedChangesModal').within(() => { + cy.getByTestSubj('confirmModalConfirmButton').click(); + }); + cy.url().should('include', 'protectionUpdates'); + }); + }); + }); } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx index 0c5a98281eec6..b7c4c13237a1f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx @@ -47,7 +47,9 @@ describe('When rendering PolicySettingsLayout', () => { apiMocks = allFleetHttpMocks(mockedContext.coreStart.http); policyData = new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy(); render = () => { - renderResult = mockedContext.render(); + renderResult = mockedContext.render( + + ); return renderResult; }; }); @@ -117,9 +119,17 @@ describe('When rendering PolicySettingsLayout', () => { return expectedUpdates; }; - it('should render layout with expected content', () => { + it('should render layout with expected content when no changes have been made', () => { const { getByTestId } = render(); + expect(getByTestId('endpointPolicyForm')); + expect(getByTestId('policyDetailsCancelButton')).not.toBeDisabled(); + expect(getByTestId('policyDetailsSaveButton')).toBeDisabled(); + }); + + it('should render layout with expected content when changes have been made', () => { + const { getByTestId } = render(); + makeUpdates(); expect(getByTestId('endpointPolicyForm')); expect(getByTestId('policyDetailsCancelButton')).not.toBeDisabled(); expect(getByTestId('policyDetailsSaveButton')).not.toBeDisabled(); @@ -141,6 +151,7 @@ describe('When rendering PolicySettingsLayout', () => { const deferred = getDeferred(); apiMocks.responseProvider.updateEndpointPolicy.mockDelay.mockReturnValue(deferred.promise); const { getByTestId } = render(); + makeUpdates(); await clickSave(true, false); await waitFor(() => { @@ -157,10 +168,11 @@ describe('When rendering PolicySettingsLayout', () => { it('should show success toast on update success', async () => { render(); + makeUpdates(); await clickSave(); await waitFor(() => { - expect(renderResult.getByTestId('policyDetailsSaveButton')).not.toBeDisabled(); + expect(renderResult.getByTestId('policyDetailsSaveButton')).toBeDisabled(); }); expect(toasts.addSuccess).toHaveBeenCalledWith({ @@ -175,6 +187,7 @@ describe('When rendering PolicySettingsLayout', () => { throw new Error('oh oh!'); }); render(); + makeUpdates(); await clickSave(); await waitFor(() => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.tsx index 1a3a62c1e2ebf..03ce2889d6f61 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.tsx @@ -12,7 +12,7 @@ import type { ApplicationStart } from '@kbn/core-application-browser'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import { useUserPrivileges } from '../../../../../common/components/user_privileges'; import { useFetchAgentByAgentPolicySummary } from '../../../../hooks/policy/use_fetch_endpoint_policy_agent_summary'; @@ -33,176 +33,194 @@ import { ConfirmUpdate } from './components/policy_form_confirm_update'; export interface PolicySettingsLayoutProps { policy: MaybeImmutable; + setUnsavedChanges: (isModified: boolean) => void; } -export const PolicySettingsLayout = memo(({ policy: _policy }) => { - const policy = _policy as PolicyData; - const { - services: { - application: { navigateToApp }, - }, - } = useKibana(); - const toasts = useToasts(); - const dispatch = useDispatch(); - const { state: locationRouteState } = useLocation(); - const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; - const { isLoading: isUpdating, mutateAsync: sendPolicyUpdate } = useUpdateEndpointPolicy(); - const { data: agentSummaryData } = useFetchAgentByAgentPolicySummary(policy.policy_id); - - const [policySettings, setPolicySettings] = useState( - cloneDeep(policy.inputs[0].config.policy.value) - ); - const [showConfirm, setShowConfirm] = useState(false); - const [routeState, setRouteState] = useState(); - - const isEditMode = canWritePolicyManagement; - const policyName = policy?.name ?? ''; - const routingOnCancelNavigateTo = routeState?.onCancelNavigateTo; - - const navigateToAppArguments = useMemo((): Parameters => { - if (routingOnCancelNavigateTo) { - return routingOnCancelNavigateTo; - } - - return [ - APP_UI_ID, - { - path: getPoliciesPath(), +export const PolicySettingsLayout = memo( + ({ policy: _policy, setUnsavedChanges }) => { + const policy = _policy as PolicyData; + const { + services: { + application: { navigateToApp }, }, - ]; - }, [routingOnCancelNavigateTo]); - - const handleSettingsOnChange: PolicySettingsFormProps['onChange'] = useCallback((updates) => { - setPolicySettings(updates.updatedPolicy); - }, []); - - const handleCancelOnClick = useNavigateToAppEventHandler(...navigateToAppArguments); - - const handleSaveOnClick = useCallback(() => { - setShowConfirm(true); - }, []); - - const handleSaveCancel = useCallback(() => { - setShowConfirm(false); - }, []); - - const handleSaveConfirmation = useCallback(() => { - const update = cloneDeep(policy); - - update.inputs[0].config.policy.value = policySettings; - sendPolicyUpdate({ policy: update }) - .then(({ item: policyItem }) => { - toasts.addSuccess({ - 'data-test-subj': 'policyDetailsSuccessMessage', - title: i18n.translate( - 'xpack.securitySolution.endpoint.policy.details.updateSuccessTitle', - { - defaultMessage: 'Success!', - } - ), - text: i18n.translate( - 'xpack.securitySolution.endpoint.policy.details.updateSuccessMessage', - { - defaultMessage: 'Integration {name} has been updated.', - values: { name: policyName }, - } - ), - }); + } = useKibana(); + const toasts = useToasts(); + const dispatch = useDispatch(); + const { state: locationRouteState } = useLocation(); + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; + const { isLoading: isUpdating, mutateAsync: sendPolicyUpdate } = useUpdateEndpointPolicy(); + const { data: agentSummaryData } = useFetchAgentByAgentPolicySummary(policy.policy_id); + + const [policySettings, setPolicySettings] = useState( + cloneDeep(policy.inputs[0].config.policy.value) + ); + + const [policyModified, setPolicyModified] = useState(false); + + const [showConfirm, setShowConfirm] = useState(false); + const [routeState, setRouteState] = useState(); + + const isEditMode = canWritePolicyManagement; + const policyName = policy?.name ?? ''; + const routingOnCancelNavigateTo = routeState?.onCancelNavigateTo; + + const navigateToAppArguments = useMemo((): Parameters => { + if (routingOnCancelNavigateTo) { + return routingOnCancelNavigateTo; + } + + return [ + APP_UI_ID, + { + path: getPoliciesPath(), + }, + ]; + }, [routingOnCancelNavigateTo]); + + const handleSettingsOnChange: PolicySettingsFormProps['onChange'] = useCallback( + (updates) => { + setPolicySettings(updates.updatedPolicy); + setPolicyModified(!isEqual(updates.updatedPolicy, policy.inputs[0].config.policy.value)); + }, + [policy.inputs] + ); + const handleCancelOnClick = useNavigateToAppEventHandler(...navigateToAppArguments); + + const handleSaveOnClick = useCallback(() => { + setShowConfirm(true); + }, []); + + const handleSaveCancel = useCallback(() => { + setShowConfirm(false); + }, []); + + const handleSaveConfirmation = useCallback(() => { + const update = cloneDeep(policy); + + update.inputs[0].config.policy.value = policySettings; + sendPolicyUpdate({ policy: update }) + .then(({ item: policyItem }) => { + toasts.addSuccess({ + 'data-test-subj': 'policyDetailsSuccessMessage', + title: i18n.translate( + 'xpack.securitySolution.endpoint.policy.details.updateSuccessTitle', + { + defaultMessage: 'Success!', + } + ), + text: i18n.translate( + 'xpack.securitySolution.endpoint.policy.details.updateSuccessMessage', + { + defaultMessage: 'Integration {name} has been updated.', + values: { name: policyName }, + } + ), + }); - if (routeState && routeState.onSaveNavigateTo) { - navigateToApp(...routeState.onSaveNavigateTo); - } else { - // Since the 'policyItem' is stored in a store and fetched as a result of an action on urlChange, we still need to dispatch an action even though Redux was removed from this component. - dispatch({ - type: 'serverReturnedPolicyDetailsData', - payload: { - policyItem, - }, + if (routeState && routeState.onSaveNavigateTo) { + navigateToApp(...routeState.onSaveNavigateTo); + } else { + setPolicyModified(false); + // Since the 'policyItem' is stored in a store and fetched as a result of an action on urlChange, we still need to dispatch an action even though Redux was removed from this component. + dispatch({ + type: 'serverReturnedPolicyDetailsData', + payload: { + policyItem, + }, + }); + } + }) + .catch((err) => { + toasts.addDanger({ + 'data-test-subj': 'policyDetailsFailureMessage', + title: i18n.translate( + 'xpack.securitySolution.endpoint.policy.details.updateErrorTitle', + { + defaultMessage: 'Failed!', + } + ), + text: err.message, }); - } - }) - .catch((err) => { - toasts.addDanger({ - 'data-test-subj': 'policyDetailsFailureMessage', - title: i18n.translate('xpack.securitySolution.endpoint.policy.details.updateErrorTitle', { - defaultMessage: 'Failed!', - }), - text: err.message, }); - }); - - handleSaveCancel(); - }, [ - dispatch, - handleSaveCancel, - navigateToApp, - policy, - policyName, - policySettings, - routeState, - sendPolicyUpdate, - toasts, - ]); - - useEffect(() => { - if (!routeState && locationRouteState) { - setRouteState(locationRouteState); - } - }, [locationRouteState, routeState]); - - return ( - <> - {showConfirm && ( - { + if (!routeState && locationRouteState) { + setRouteState(locationRouteState); + } + }, [locationRouteState, routeState]); + + useEffect(() => { + setUnsavedChanges(policyModified); + }, [policyModified, setUnsavedChanges]); + + return ( + <> + {showConfirm && ( + + )} + + - )} - - - - - - - - - - - - - {isEditMode && ( + + + + + - - + - )} - - - - ); -}); + {isEditMode && ( + + + + + + )} + + + + ); + } +); PolicySettingsLayout.displayName = 'PolicySettingsLayout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/protection_updates/protection_updates_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/protection_updates/protection_updates_layout.tsx index d99a7682dfd90..5d244884c1068 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/protection_updates/protection_updates_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/protection_updates/protection_updates_layout.tsx @@ -43,6 +43,7 @@ import { getControlledArtifactCutoffDate } from '../../../../../../common/endpoi interface ProtectionUpdatesLayoutProps { policy: MaybeImmutable; + setUnsavedChanges: (isModified: boolean) => void; } const AUTOMATIC_UPDATES_CHECKBOX_LABEL = i18n.translate( @@ -60,7 +61,7 @@ const AUTOMATIC_UPDATES_OFF_CHECKBOX_LABEL = i18n.translate( ); export const ProtectionUpdatesLayout = React.memo( - ({ policy: _policy }) => { + ({ policy: _policy, setUnsavedChanges }) => { const toasts = useToasts(); const dispatch = useDispatch(); const { isLoading: isUpdating, mutateAsync: sendPolicyUpdate } = useUpdateEndpointPolicy(); @@ -106,6 +107,10 @@ export const ProtectionUpdatesLayout = React.memo( (fetchedNote ? note !== fetchedNote.note : note !== '') || manifestVersion !== deployedVersion; + useEffect(() => { + setUnsavedChanges(saveButtonEnabled); + }, [saveButtonEnabled, setUnsavedChanges]); + const onSave = useCallback(() => { const update = cloneDeep(policy); update.inputs[0].config.policy.value.global_manifest_version = manifestVersion; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx index cdbc253ec6776..5b37aa798effd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx @@ -9,8 +9,9 @@ import type { EuiTabbedContentTab } from '@elastic/eui'; import { EuiSpacer, EuiTabbedContent } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import { UnsavedChangesConfirmModal } from './unsaved_changes_confirm_modal'; import { useLicense } from '../../../../../common/hooks/use_license'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { ProtectionUpdatesLayout } from '../protection_updates/protection_updates_layout'; @@ -85,6 +86,28 @@ export const PolicyTabs = React.memo(() => { const isInProtectionUpdatesTab = usePolicyDetailsSelector(isOnProtectionUpdatesView); const policyId = usePolicyDetailsSelector(policyIdFromParams); + const [unsavedChangesModal, setUnsavedChangesModal] = useState<{ + showModal: boolean; + nextTab: EuiTabbedContentTab | null; + }>({ showModal: false, nextTab: null }); + + const [unsavedChanges, setUnsavedChanges] = useState< + Record + >({ + [PolicyTabKeys.SETTINGS]: false, + [PolicyTabKeys.PROTECTION_UPDATES]: false, + }); + + const setTabUnsavedChanges = useCallback( + (tab: PolicyTabKeys.SETTINGS | PolicyTabKeys.PROTECTION_UPDATES) => + (hasUnsavedChanges: boolean) => { + if (unsavedChanges[tab] !== hasUnsavedChanges) { + setUnsavedChanges((prev) => ({ ...prev, [tab]: hasUnsavedChanges })); + } + }, + [unsavedChanges] + ); + // By the time the tabs load, we know that we already have a `policyItem` since a conditional // check is done at the `PageDetails` component level. So asserting to non-null/undefined here. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -213,9 +236,13 @@ export const PolicyTabs = React.memo(() => { <> - + ), + 'data-test-subj': 'policySettingsTab', }, [PolicyTabKeys.TRUSTED_APPS]: canReadTrustedApplications ? { @@ -240,6 +267,7 @@ export const PolicyTabs = React.memo(() => { /> ), + 'data-test-subj': 'policyTrustedAppsTab', } : undefined, [PolicyTabKeys.EVENT_FILTERS]: canReadEventFilters @@ -265,6 +293,7 @@ export const PolicyTabs = React.memo(() => { /> ), + 'data-test-subj': 'policyEventFiltersTab', } : undefined, [PolicyTabKeys.HOST_ISOLATION_EXCEPTIONS]: canReadHostIsolationExceptions @@ -290,6 +319,7 @@ export const PolicyTabs = React.memo(() => { /> ), + 'data-test-subj': 'policyHostIsolationExceptionsTab', } : undefined, [PolicyTabKeys.BLOCKLISTS]: canReadBlocklist @@ -312,6 +342,7 @@ export const PolicyTabs = React.memo(() => { /> ), + 'data-test-subj': 'policyBlocklistTab', } : undefined, @@ -327,14 +358,19 @@ export const PolicyTabs = React.memo(() => { content: ( <> - + ), + 'data-test-subj': 'policyProtectionUpdatesTab', } : undefined, }; }, [ policyItem, + setTabUnsavedChanges, canReadTrustedApplications, getTrustedAppsApiClientInstance, canWriteTrustedApplications, @@ -385,8 +421,15 @@ export const PolicyTabs = React.memo(() => { isInProtectionUpdatesTab, ]); - const onTabClickHandler = useCallback( + const cancelUnsavedChangesModal = useCallback(() => { + setUnsavedChangesModal({ showModal: false, nextTab: null }); + }, [setUnsavedChangesModal]); + + const changeTab = useCallback( (selectedTab: EuiTabbedContentTab) => { + if (unsavedChangesModal.showModal) { + cancelUnsavedChangesModal(); + } let path: string = ''; switch (selectedTab.id) { case PolicyTabKeys.SETTINGS: @@ -410,21 +453,56 @@ export const PolicyTabs = React.memo(() => { } history.push(path, routeState?.backLink ? { backLink: routeState.backLink } : null); }, - [history, policyId, routeState] + [ + cancelUnsavedChangesModal, + history, + policyId, + routeState.backLink, + unsavedChangesModal.showModal, + ] ); + const onTabClickHandler = useCallback( + (selectedTab: EuiTabbedContentTab) => { + if ( + (isInSettingsTab && unsavedChanges[PolicyTabKeys.SETTINGS]) || + (isInProtectionUpdatesTab && unsavedChanges[PolicyTabKeys.PROTECTION_UPDATES]) + ) { + setUnsavedChangesModal({ showModal: true, nextTab: selectedTab }); + } else { + changeTab(selectedTab); + } + }, + [changeTab, isInProtectionUpdatesTab, isInSettingsTab, unsavedChanges] + ); + + const confirmUnsavedChangesModal = useCallback(() => { + if (unsavedChangesModal.nextTab) { + changeTab(unsavedChangesModal.nextTab); + } + }, [changeTab, unsavedChangesModal.nextTab]); + // show loader for privileges validation if (privilegesLoading) { return ; } return ( - + <> + {unsavedChangesModal.showModal && ( + + )} + + ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/unsaved_changes_confirm_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/unsaved_changes_confirm_modal.tsx new file mode 100644 index 0000000000000..b7e1b06567081 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/unsaved_changes_confirm_modal.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiConfirmModal } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export const UnsavedChangesConfirmModal = React.memo<{ + onConfirm: () => void; + onCancel: () => void; +}>(({ onCancel, onConfirm }) => { + return ( + + + + ); +}); + +UnsavedChangesConfirmModal.displayName = 'UnsavedChangesConfirmModal'; From b5225232d038ea8d29f4f21c4bd528209e1b365e Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 15 Feb 2024 09:40:48 -0500 Subject: [PATCH 03/23] [Response Ops][Task Manager] Introduce task priority during task claiming (#175334) Resolves https://github.com/elastic/kibana/issues/174352 ## Summary Adds an optional `priority` definition to task types which defaults to `Normal` priority. Updates the task claiming update by query to include a new scripted sort that sorts by priority in descending order so that highest priority tasks are claimed first. This priority field is planned for use by backfill rule execution tasks so the only usages in this PR are in the functional tests. Also included an integration test that will ping the team if a task type explicitly sets a priority in the task definition --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/task_manager/server/index.ts | 2 +- .../task_priority_check.test.ts.snap | 3 + .../task_priority_check.test.ts | 60 +++++ x-pack/plugins/task_manager/server/task.ts | 18 +- .../task_claimers/strategy_default.test.ts | 25 +- .../server/task_claimers/strategy_default.ts | 50 +++- .../server/task_type_dictionary.test.ts | 221 ++++++++++++------ .../server/task_type_dictionary.ts | 8 +- .../sample_task_plugin/server/init_routes.ts | 1 + .../sample_task_plugin/server/plugin.ts | 31 +++ .../check_registered_task_types.ts | 1 + .../test_suites/task_manager/index.ts | 1 + .../test_suites/task_manager/task_priority.ts | 216 +++++++++++++++++ 13 files changed, 550 insertions(+), 87 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/integration_tests/__snapshots__/task_priority_check.test.ts.snap create mode 100644 x-pack/plugins/task_manager/server/integration_tests/task_priority_check.test.ts create mode 100644 x-pack/test/plugin_api_integration/test_suites/task_manager/task_priority.ts diff --git a/x-pack/plugins/task_manager/server/index.ts b/x-pack/plugins/task_manager/server/index.ts index a7555b3316a8e..92153a4878d76 100644 --- a/x-pack/plugins/task_manager/server/index.ts +++ b/x-pack/plugins/task_manager/server/index.ts @@ -24,7 +24,7 @@ export type { LoadIndirectParamsResult, } from './task'; -export { TaskStatus } from './task'; +export { TaskStatus, TaskPriority } from './task'; export type { TaskRegisterDefinition, TaskDefinitionRegistry } from './task_type_dictionary'; diff --git a/x-pack/plugins/task_manager/server/integration_tests/__snapshots__/task_priority_check.test.ts.snap b/x-pack/plugins/task_manager/server/integration_tests/__snapshots__/task_priority_check.test.ts.snap new file mode 100644 index 0000000000000..75726039709fa --- /dev/null +++ b/x-pack/plugins/task_manager/server/integration_tests/__snapshots__/task_priority_check.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Task priority checks detects tasks with priority definitions 1`] = `Array []`; diff --git a/x-pack/plugins/task_manager/server/integration_tests/task_priority_check.test.ts b/x-pack/plugins/task_manager/server/integration_tests/task_priority_check.test.ts new file mode 100644 index 0000000000000..ebbea6f1e8a07 --- /dev/null +++ b/x-pack/plugins/task_manager/server/integration_tests/task_priority_check.test.ts @@ -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 { + type TestElasticsearchUtils, + type TestKibanaUtils, +} from '@kbn/core-test-helpers-kbn-server'; +import { TaskDefinition, TaskPriority } from '../task'; +import { setupTestServers } from './lib'; +import { TaskTypeDictionary } from '../task_type_dictionary'; + +jest.mock('../task_type_dictionary', () => { + const actual = jest.requireActual('../task_type_dictionary'); + return { + ...actual, + TaskTypeDictionary: jest.fn().mockImplementation((opts) => { + return new actual.TaskTypeDictionary(opts); + }), + }; +}); + +// Notify response-ops if a task sets a priority to something other than `Normal` +describe('Task priority checks', () => { + let esServer: TestElasticsearchUtils; + let kibanaServer: TestKibanaUtils; + let taskTypeDictionary: TaskTypeDictionary; + + beforeAll(async () => { + const setupResult = await setupTestServers(); + esServer = setupResult.esServer; + kibanaServer = setupResult.kibanaServer; + + const mockedTaskTypeDictionary = jest.requireMock('../task_type_dictionary'); + expect(mockedTaskTypeDictionary.TaskTypeDictionary).toHaveBeenCalledTimes(1); + taskTypeDictionary = mockedTaskTypeDictionary.TaskTypeDictionary.mock.results[0].value; + }); + + afterAll(async () => { + if (kibanaServer) { + await kibanaServer.stop(); + } + if (esServer) { + await esServer.stop(); + } + }); + + it('detects tasks with priority definitions', async () => { + const taskTypes = taskTypeDictionary.getAllDefinitions(); + const taskTypesWithPriority = taskTypes + .map((taskType: TaskDefinition) => + !!taskType.priority ? { taskType: taskType.type, priority: taskType.priority } : null + ) + .filter((tt: { taskType: string; priority: TaskPriority } | null) => null != tt); + expect(taskTypesWithPriority).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts index 0d064153859a5..b7b86c50c8b08 100644 --- a/x-pack/plugins/task_manager/server/task.ts +++ b/x-pack/plugins/task_manager/server/task.ts @@ -6,10 +6,16 @@ */ import { ObjectType, schema, TypeOf } from '@kbn/config-schema'; +import { isNumber } from 'lodash'; import { isErr, tryAsResult } from './lib/result_type'; import { Interval, isInterval, parseIntervalAsMillisecond } from './lib/intervals'; import { DecoratedError } from './task_running'; +export enum TaskPriority { + Low = 1, + Normal = 50, +} + /* * Type definitions and validations for tasks. */ @@ -132,6 +138,10 @@ export const taskDefinitionSchema = schema.object( * A brief, human-friendly title for this task. */ title: schema.maybe(schema.string()), + /** + * Priority of this task type. Defaults to "NORMAL" if not defined + */ + priority: schema.maybe(schema.number()), /** * An optional more detailed description of what this task does. */ @@ -179,10 +189,16 @@ export const taskDefinitionSchema = schema.object( indirectParamsSchema: schema.maybe(schema.any()), }, { - validate({ timeout }) { + validate({ timeout, priority }) { if (!isInterval(timeout) || isErr(tryAsResult(() => parseIntervalAsMillisecond(timeout)))) { return `Invalid timeout "${timeout}". Timeout must be of the form "{number}{cadance}" where number is an integer. Example: 5m.`; } + + if (priority && (!isNumber(priority) || !(priority in TaskPriority))) { + return `Invalid priority "${priority}". Priority must be one of ${Object.keys(TaskPriority) + .filter((key) => isNaN(Number(key))) + .map((key) => `${key} => ${TaskPriority[key as keyof typeof TaskPriority]}`)}`; + } }, } ); diff --git a/x-pack/plugins/task_manager/server/task_claimers/strategy_default.test.ts b/x-pack/plugins/task_manager/server/task_claimers/strategy_default.test.ts index c89ecdf669218..d6911b1b67e20 100644 --- a/x-pack/plugins/task_manager/server/task_claimers/strategy_default.test.ts +++ b/x-pack/plugins/task_manager/server/task_claimers/strategy_default.test.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { v1 as uuidv1, v4 as uuidv4 } from 'uuid'; import { filter, take, toArray } from 'rxjs/operators'; -import { TaskStatus, ConcreteTaskInstance } from '../task'; +import { TaskStatus, ConcreteTaskInstance, TaskPriority } from '../task'; import { SearchOpts, StoreOpts, UpdateByQueryOpts, UpdateByQuerySearchOpts } from '../task_store'; import { asTaskClaimEvent, TaskEvent } from '../task_events'; import { asOk, isOk, unwrap } from '../lib/result_type'; @@ -255,6 +255,7 @@ describe('TaskClaiming', () => { definitions.registerTaskDefinitions({ foo: { title: 'foo', + priority: TaskPriority.Low, createTaskRunner: jest.fn(), }, bar: { @@ -351,6 +352,28 @@ describe('TaskClaiming', () => { }, }); expect(sort).toMatchObject([ + { + _script: { + type: 'number', + order: 'desc', + script: { + lang: 'painless', + params: { + priority_map: { + foo: 1, + }, + }, + source: ` + String taskType = doc['task.taskType'].value; + if (params.priority_map.containsKey(taskType)) { + return params.priority_map[taskType]; + } else { + return 50; + } + `, + }, + }, + }, { _script: { type: 'number', diff --git a/x-pack/plugins/task_manager/server/task_claimers/strategy_default.ts b/x-pack/plugins/task_manager/server/task_claimers/strategy_default.ts index 0d9ccb2ef723d..93101b19cd77f 100644 --- a/x-pack/plugins/task_manager/server/task_claimers/strategy_default.ts +++ b/x-pack/plugins/task_manager/server/task_claimers/strategy_default.ts @@ -8,6 +8,7 @@ /* * This module contains helpers for managing the task manager storage layer. */ +import type { estypes } from '@elastic/elasticsearch'; import apm from 'elastic-apm-node'; import minimatch from 'minimatch'; import { Subject, Observable, from, of } from 'rxjs'; @@ -17,7 +18,7 @@ import { groupBy, pick } from 'lodash'; import { asOk } from '../lib/result_type'; import { TaskTypeDictionary } from '../task_type_dictionary'; import { TaskClaimerOpts, ClaimOwnershipResult } from '.'; -import { ConcreteTaskInstance } from '../task'; +import { ConcreteTaskInstance, TaskPriority } from '../task'; import { TASK_MANAGER_TRANSACTION_TYPE } from '../task_running'; import { isLimited, TASK_MANAGER_MARK_AS_CLAIMED } from '../queries/task_claiming'; import { TaskClaim, asTaskClaimEvent, startTaskTimer } from '../task_events'; @@ -104,11 +105,12 @@ export function claimAvailableTasksDefault( async function executeClaimAvailableTasks( opts: OwnershipClaimingOpts ): Promise { - const { taskStore, size, taskTypes, events$ } = opts; + const { taskStore, size, taskTypes, events$, definitions } = opts; const { updated: tasksUpdated, version_conflicts: tasksConflicted } = await markAvailableTasksAsClaimed(opts); - const docs = tasksUpdated > 0 ? await sweepForClaimedTasks(taskStore, taskTypes, size) : []; + const docs = + tasksUpdated > 0 ? await sweepForClaimedTasks(taskStore, taskTypes, size, definitions) : []; emitEvents( events$, @@ -166,7 +168,7 @@ async function markAvailableTasksAsClaimed({ shouldBeOneOf(IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt) ); - const sort: NonNullable = [SortByRunAtAndRetryAt]; + const sort: NonNullable = getClaimSort(definitions); const query = matchesClauses(queryForScheduledTasks, filterDownBy(InactiveTasks)); const script = updateFieldsAndMarkAsFailed({ fieldUpdates: { @@ -206,7 +208,8 @@ async function markAvailableTasksAsClaimed({ async function sweepForClaimedTasks( taskStore: TaskStore, taskTypes: Set, - size: number + size: number, + definitions: TaskTypeDictionary ): Promise { const claimedTasksQuery = tasksClaimedByOwner( taskStore.taskManagerId, @@ -215,7 +218,7 @@ async function sweepForClaimedTasks( const { docs } = await taskStore.fetch({ query: claimedTasksQuery, size, - sort: SortByRunAtAndRetryAt, + sort: getClaimSort(definitions), seq_no_primary_term: true, }); @@ -253,3 +256,38 @@ function accumulateClaimOwnershipResults( } return prev; } + +function getClaimSort(definitions: TaskTypeDictionary): estypes.SortCombinations[] { + // Sort by descending priority, then by ascending runAt/retryAt time + return [ + { + _script: { + type: 'number', + order: 'desc', + script: { + lang: 'painless', + // Use priority if explicitly specified in task definition, otherwise default to 50 (Normal) + source: ` + String taskType = doc['task.taskType'].value; + if (params.priority_map.containsKey(taskType)) { + return params.priority_map[taskType]; + } else { + return ${TaskPriority.Normal}; + } + `, + params: { + priority_map: definitions + .getAllDefinitions() + .reduce>((acc, taskDefinition) => { + if (taskDefinition.priority) { + acc[taskDefinition.type] = taskDefinition.priority; + } + return acc; + }, {}), + }, + }, + }, + }, + SortByRunAtAndRetryAt, + ]; +} diff --git a/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts b/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts index 039403816da5e..d1b44a7577025 100644 --- a/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts +++ b/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts @@ -6,7 +6,7 @@ */ import { get } from 'lodash'; -import { RunContext, TaskDefinition } from './task'; +import { RunContext, TaskDefinition, TaskPriority } from './task'; import { mockLogger } from './test_utils'; import { sanitizeTaskDefinitions, @@ -50,17 +50,18 @@ const getMockTaskDefinitions = (opts: Opts) => { describe('taskTypeDictionary', () => { let definitions: TaskTypeDictionary; + const logger = mockLogger(); beforeEach(() => { - definitions = new TaskTypeDictionary(mockLogger()); + definitions = new TaskTypeDictionary(logger); }); - describe('sanitizeTaskDefinitions', () => {}); - it('provides tasks with defaults', () => { - const taskDefinitions = getMockTaskDefinitions({ numTasks: 3 }); - const result = sanitizeTaskDefinitions(taskDefinitions); + describe('sanitizeTaskDefinitions', () => { + it('provides tasks with defaults', () => { + const taskDefinitions = getMockTaskDefinitions({ numTasks: 3 }); + const result = sanitizeTaskDefinitions(taskDefinitions); - expect(result).toMatchInlineSnapshot(` + expect(result).toMatchInlineSnapshot(` Array [ Object { "createTaskRunner": [Function], @@ -85,89 +86,117 @@ describe('taskTypeDictionary', () => { }, ] `); - }); + }); - it('throws a validation exception for invalid task definition', () => { - const runsanitize = () => { - const taskDefinitions: TaskDefinitionRegistry = { - some_kind_of_task: { - // @ts-ignore - fail: 'extremely', // cause a validation failure - type: 'breaky_task', - title: 'Test XYZ', - description: `Actually this won't work`, - createTaskRunner() { - return { - async run() { - return { - state: {}, - }; - }, - }; + it('throws a validation exception for invalid task definition', () => { + const runsanitize = () => { + const taskDefinitions: TaskDefinitionRegistry = { + some_kind_of_task: { + // @ts-ignore + fail: 'extremely', // cause a validation failure + type: 'breaky_task', + title: 'Test XYZ', + description: `Actually this won't work`, + createTaskRunner() { + return { + async run() { + return { + state: {}, + }; + }, + }; + }, }, - }, - }; + }; - return sanitizeTaskDefinitions(taskDefinitions); - }; + return sanitizeTaskDefinitions(taskDefinitions); + }; - expect(runsanitize).toThrowErrorMatchingInlineSnapshot( - `"[fail]: definition for this key is missing"` - ); - }); + expect(runsanitize).toThrowErrorMatchingInlineSnapshot( + `"[fail]: definition for this key is missing"` + ); + }); - it('throws a validation exception for invalid timeout on task definition', () => { - const runsanitize = () => { - const taskDefinitions: TaskDefinitionRegistry = { - some_kind_of_task: { - title: 'Test XYZ', - timeout: '15 days', - description: `Actually this won't work`, - createTaskRunner() { - return { - async run() { - return { - state: {}, - }; - }, - }; + it('throws a validation exception for invalid timeout on task definition', () => { + const runsanitize = () => { + const taskDefinitions: TaskDefinitionRegistry = { + some_kind_of_task: { + title: 'Test XYZ', + timeout: '15 days', + description: `Actually this won't work`, + createTaskRunner() { + return { + async run() { + return { + state: {}, + }; + }, + }; + }, }, - }, - }; + }; - return sanitizeTaskDefinitions(taskDefinitions); - }; + return sanitizeTaskDefinitions(taskDefinitions); + }; - expect(runsanitize).toThrowErrorMatchingInlineSnapshot( - `"Invalid timeout \\"15 days\\". Timeout must be of the form \\"{number}{cadance}\\" where number is an integer. Example: 5m."` - ); - }); + expect(runsanitize).toThrowErrorMatchingInlineSnapshot( + `"Invalid timeout \\"15 days\\". Timeout must be of the form \\"{number}{cadance}\\" where number is an integer. Example: 5m."` + ); + }); - it('throws a validation exception for invalid floating point timeout on task definition', () => { - const runsanitize = () => { - const taskDefinitions: TaskDefinitionRegistry = { - some_kind_of_task: { - title: 'Test XYZ', - timeout: '1.5h', - description: `Actually this won't work`, - createTaskRunner() { - return { - async run() { - return { - state: {}, - }; - }, - }; + it('throws a validation exception for invalid floating point timeout on task definition', () => { + const runsanitize = () => { + const taskDefinitions: TaskDefinitionRegistry = { + some_kind_of_task: { + title: 'Test XYZ', + timeout: '1.5h', + description: `Actually this won't work`, + createTaskRunner() { + return { + async run() { + return { + state: {}, + }; + }, + }; + }, }, - }, + }; + + return sanitizeTaskDefinitions(taskDefinitions); }; - return sanitizeTaskDefinitions(taskDefinitions); - }; + expect(runsanitize).toThrowErrorMatchingInlineSnapshot( + `"Invalid timeout \\"1.5h\\". Timeout must be of the form \\"{number}{cadance}\\" where number is an integer. Example: 5m."` + ); + }); + + it('throws a validation exception for invalid priority on task definition', () => { + const runsanitize = () => { + const taskDefinitions: TaskDefinitionRegistry = { + some_kind_of_task: { + title: 'Test XYZ', + priority: 23, + description: `Actually this won't work`, + createTaskRunner() { + return { + async run() { + return { + state: {}, + }; + }, + }; + }, + }, + }; + + return sanitizeTaskDefinitions(taskDefinitions); + }; - expect(runsanitize).toThrowErrorMatchingInlineSnapshot( - `"Invalid timeout \\"1.5h\\". Timeout must be of the form \\"{number}{cadance}\\" where number is an integer. Example: 5m."` - ); + expect(runsanitize).toThrowErrorMatchingInlineSnapshot( + `"Invalid priority \\"23\\". Priority must be one of Low => 1,Normal => 50"` + ); + }); }); describe('registerTaskDefinitions', () => { @@ -182,6 +211,44 @@ describe('taskTypeDictionary', () => { expect(definitions.has('foo')).toBe(true); }); + it('uses task priority if specified', () => { + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + maxConcurrency: 2, + priority: TaskPriority.Low, + createTaskRunner: jest.fn(), + }, + }); + expect(definitions.get('foo')).toEqual({ + createTaskRunner: expect.any(Function), + maxConcurrency: 2, + priority: 1, + timeout: '5m', + title: 'foo', + type: 'foo', + }); + }); + + it('does not register task with invalid priority schema', () => { + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + maxConcurrency: 2, + priority: 23, + createTaskRunner: jest.fn(), + }, + }); + expect(logger.error).toHaveBeenCalledWith( + `Could not sanitize task definitions: Invalid priority \"23\". Priority must be one of Low => 1,Normal => 50` + ); + expect(() => { + definitions.get('foo'); + }).toThrowErrorMatchingInlineSnapshot( + `"Unsupported task type \\"foo\\". Supported types are "` + ); + }); + it('throws error when registering duplicate task type', () => { definitions.registerTaskDefinitions({ foo: { diff --git a/x-pack/plugins/task_manager/server/task_type_dictionary.ts b/x-pack/plugins/task_manager/server/task_type_dictionary.ts index 84df658f36179..a29504f04f303 100644 --- a/x-pack/plugins/task_manager/server/task_type_dictionary.ts +++ b/x-pack/plugins/task_manager/server/task_type_dictionary.ts @@ -7,7 +7,7 @@ import { ObjectType } from '@kbn/config-schema'; import { Logger } from '@kbn/core/server'; -import { TaskDefinition, taskDefinitionSchema, TaskRunCreatorFunction } from './task'; +import { TaskDefinition, taskDefinitionSchema, TaskRunCreatorFunction, TaskPriority } from './task'; import { CONCURRENCY_ALLOW_LIST_BY_TASK_TYPE } from './constants'; /** @@ -44,6 +44,12 @@ export interface TaskRegisterDefinition { * the task will be re-attempted. */ timeout?: string; + /** + * An optional definition of task priority. Tasks will be sorted by priority prior to claiming + * so high priority tasks will always be claimed before normal priority, which will always be + * claimed before low priority + */ + priority?: TaskPriority; /** * An optional more detailed description of what this task does. */ diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts index 274bac5fc5dc0..01f8cd6ba4bc9 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts @@ -300,6 +300,7 @@ export function initRoutes( const taskManager = await taskManagerStart; return res.ok({ body: await taskManager.fetch({ + size: 20, query: taskManagerQuery, }), }); diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts index d7bb483fcac01..0e3a2bb993fe7 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts @@ -18,6 +18,7 @@ import { EphemeralTask, } from '@kbn/task-manager-plugin/server'; import { DEFAULT_MAX_WORKERS } from '@kbn/task-manager-plugin/server/config'; +import { TaskPriority } from '@kbn/task-manager-plugin/server/task'; import { initRoutes } from './init_routes'; // this plugin's dependendencies @@ -242,6 +243,36 @@ export class SampleTaskManagerFixturePlugin async run() {}, }), }, + lowPriorityTask: { + title: 'Task used for testing priority claiming', + priority: TaskPriority.Low, + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => ({ + async run() { + const { state, schedule } = taskInstance; + const prevState = state || { count: 0 }; + + const count = (prevState.count || 0) + 1; + + const [{ elasticsearch }] = await core.getStartServices(); + await elasticsearch.client.asInternalUser.index({ + index: '.kibana_task_manager_test_result', + body: { + type: 'task', + taskType: 'lowPriorityTask', + taskId: taskInstance.id, + state: JSON.stringify(state), + ranAt: new Date(), + }, + refresh: true, + }); + + return { + state: { count }, + schedule, + }; + }, + }), + }, }); const taskWithTiming = { diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 1f52d3ca24f90..3b71c99df2e5b 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -20,6 +20,7 @@ export default function ({ getService }: FtrProviderContext) { } const TEST_TYPES = [ + 'lowPriorityTask', 'sampleOneTimeTaskThrowingError', 'sampleRecurringTaskTimingOut', 'sampleRecurringTaskWhichHangs', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/index.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/index.ts index 420dfe795f322..1480d5f371a77 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/index.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('task_manager', function taskManagerSuite() { + loadTestFile(require.resolve('./task_priority')); loadTestFile(require.resolve('./background_task_utilization_route')); loadTestFile(require.resolve('./metrics_route')); loadTestFile(require.resolve('./health_route')); diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_priority.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_priority.ts new file mode 100644 index 0000000000000..f8fc3f63987b9 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_priority.ts @@ -0,0 +1,216 @@ +/* + * 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 expect from '@kbn/expect'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; +import { taskMappings as TaskManagerMapping } from '@kbn/task-manager-plugin/server/saved_objects/mappings'; +import { asyncForEach } from '@kbn/std'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const { properties: taskManagerIndexMapping } = TaskManagerMapping; + +export interface RawDoc { + _id: string; + _source: any; + _type?: string; +} +export interface SearchResults { + hits: { + hits: RawDoc[]; + }; +} + +type DeprecatedConcreteTaskInstance = Omit & { + interval: string; +}; + +type SerializedConcreteTaskInstance = Omit< + ConcreteTaskInstance, + 'state' | 'params' | 'scheduledAt' | 'startedAt' | 'retryAt' | 'runAt' +> & { + state: State; + params: Params; + scheduledAt: string; + startedAt: string | null; + retryAt: string | null; + runAt: string; +}; + +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const retry = getService('retry'); + const supertest = getService('supertest'); + + const testHistoryIndex = '.kibana_task_manager_test_result'; + + function scheduleTask( + task: Partial + ): Promise { + return supertest + .post('/api/sample_tasks/schedule') + .set('kbn-xsrf', 'xxx') + .send({ task }) + .expect(200) + .then((response: { body: SerializedConcreteTaskInstance }) => response.body); + } + + function currentTasks(): Promise<{ + docs: Array>; + }> { + return supertest + .get('/api/sample_tasks') + .expect(200) + .then((response) => response.body); + } + + async function historyDocs({ + taskId, + taskType, + }: { + taskId?: string; + taskType?: string; + }): Promise { + const filter: any[] = [{ term: { type: 'task' } }]; + if (taskId) { + filter.push({ term: { taskId } }); + } + if (taskType) { + filter.push({ term: { taskType } }); + } + return es + .search({ + index: testHistoryIndex, + body: { + query: { + bool: { + filter, + }, + }, + }, + }) + .then((result) => (result as unknown as SearchResults).hits.hits); + } + + describe('task priority', () => { + beforeEach(async () => { + const exists = await es.indices.exists({ index: testHistoryIndex }); + if (exists) { + await es.deleteByQuery({ + index: testHistoryIndex, + refresh: true, + body: { query: { term: { type: 'task' } } }, + }); + } else { + await es.indices.create({ + index: testHistoryIndex, + body: { + mappings: { + properties: { + type: { + type: 'keyword', + }, + taskId: { + type: 'keyword', + }, + params: taskManagerIndexMapping.params, + state: taskManagerIndexMapping.state, + runAt: taskManagerIndexMapping.runAt, + } as Record, + }, + }, + }); + } + }); + + afterEach(async () => { + await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200); + }); + + it('should claim low priority tasks if there is capacity', async () => { + // schedule 5 normal tasks and 1 low priority task + // setting the schedule long so they should only run once + const tasksToSchedule = []; + for (let i = 0; i < 5; i++) { + tasksToSchedule.push( + scheduleTask({ + taskType: 'sampleTask', + schedule: { interval: `1d` }, + params: {}, + }) + ); + } + tasksToSchedule.push( + scheduleTask({ + taskType: 'lowPriorityTask', + schedule: { interval: `1d` }, + params: {}, + }) + ); + const scheduledTasks = await Promise.all(tasksToSchedule); + + await retry.try(async () => { + const tasks = (await currentTasks()).docs; + expect(tasks.length).to.eql(6); + + const taskIds = tasks.map((task) => task.id); + const taskDocs: RawDoc[] = []; + await asyncForEach(scheduledTasks, async (scheduledTask) => { + expect(taskIds).to.contain(scheduledTask.id); + const doc: RawDoc[] = await historyDocs({ taskId: scheduledTask.id }); + expect(doc.length).to.eql(1); + taskDocs.push(...doc); + }); + + expect( + taskDocs.findIndex((taskDoc) => taskDoc._source.taskType === 'lowPriorityTask') + ).to.be.greaterThan(-1); + }); + }); + + it('should not claim low priority tasks when there is no capacity', async () => { + // schedule a bunch of normal priority tasks that run frequently + const tasksToSchedule = []; + for (let i = 0; i < 10; i++) { + tasksToSchedule.push( + scheduleTask({ + taskType: 'sampleTask', + schedule: { interval: `1s` }, + params: {}, + }) + ); + } + + // schedule a low priority task + tasksToSchedule.push( + scheduleTask({ + taskType: 'lowPriorityTask', + schedule: { interval: `1s` }, + params: {}, + }) + ); + const scheduledTasks = await Promise.all(tasksToSchedule); + + // make sure all tasks get created + await retry.try(async () => { + const tasks = (await currentTasks()).docs; + expect(tasks.length).to.eql(11); + + const taskIds = tasks.map((task) => task.id); + scheduledTasks.forEach((scheduledTask) => { + expect(taskIds).to.contain(scheduledTask.id); + }); + }); + + // wait for 30 seconds to let the multiple task claiming cycles run + await new Promise((r) => setTimeout(r, 30000)); + + const docs: RawDoc[] = await historyDocs({ taskType: 'lowPriorityTask' }); + expect(docs.length).to.eql(0); + }); + }); +} From 930b0127929009bbe58298983e3018a95fcec8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Thu, 15 Feb 2024 16:10:16 +0100 Subject: [PATCH 04/23] [Obs AI Assistant] Add time range to conversation (#176925) This adds the time range to the screen context. This way the assistant will respond with visualisations for the selected range, instead of defaulting to the past 24 hours. image _When selecting the past 3 hours the visualisation produced by the assistant adheres to this_ --- .../components/app/service_overview/index.tsx | 19 ++++++++++++++++++- .../observability_ai_assistant/kibana.jsonc | 7 +++---- .../action_menu_item/action_menu_item.tsx | 18 +++++++++++------- .../components/chat/welcome_message.tsx | 4 ++-- .../public/types.ts | 3 +++ .../observability_ai_assistant/tsconfig.json | 3 ++- 6 files changed, 39 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 743843c825210..9813adc5d8a3e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useEffect } from 'react'; import { EuiFlexGroup, @@ -16,6 +16,7 @@ import { EuiPanel, EuiSpacer, } from '@elastic/eui'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { isOpenTelemetryAgentName, @@ -52,6 +53,22 @@ export function ServiceOverview() { const { serviceName, fallbackToTransactions, agentName, serverlessType } = useApmServiceContext(); + const { setScreenContext } = + useApmPluginContext().observabilityAIAssistant.service; + + useEffect(() => { + return setScreenContext({ + screenDescription: `The user is looking at the service overview page for ${serviceName}.`, + data: [ + { + name: 'service_name', + description: 'The name of the service', + value: serviceName, + }, + ], + }); + }, [setScreenContext, serviceName]); + const { query, query: { kuery, environment, rangeFrom, rangeTo, transactionType }, diff --git a/x-pack/plugins/observability_ai_assistant/kibana.jsonc b/x-pack/plugins/observability_ai_assistant/kibana.jsonc index a3eaad0d216a3..2ca4b560393f0 100644 --- a/x-pack/plugins/observability_ai_assistant/kibana.jsonc +++ b/x-pack/plugins/observability_ai_assistant/kibana.jsonc @@ -10,6 +10,7 @@ "requiredPlugins": [ "alerting", "actions", + "data", "dataViews", "features", "lens", @@ -24,10 +25,8 @@ "dataViews", "ml" ], - "requiredBundles": [ "kibanaReact", "kibanaUtils"], - "optionalPlugins": [ - "cloud" - ], + "requiredBundles": ["kibanaReact", "kibanaUtils"], + "optionalPlugins": ["cloud"], "extraPublicDirs": [] } } diff --git a/x-pack/plugins/observability_ai_assistant/public/components/action_menu_item/action_menu_item.tsx b/x-pack/plugins/observability_ai_assistant/public/components/action_menu_item/action_menu_item.tsx index c22d32e7a49d7..ca8af6956ff36 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/action_menu_item/action_menu_item.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/action_menu_item/action_menu_item.tsx @@ -5,16 +5,20 @@ * 2.0. */ import React, { useEffect, useMemo, useState } from 'react'; +import datemath from '@elastic/datemath'; import { EuiFlexGroup, EuiFlexItem, EuiHeaderLink, EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import moment from 'moment'; import { ObservabilityAIAssistantChatServiceProvider } from '../../context/observability_ai_assistant_chat_service_provider'; import { useAbortableAsync } from '../../hooks/use_abortable_async'; import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant'; import { AssistantAvatar } from '../assistant_avatar'; import { ChatFlyout } from '../chat/chat_flyout'; +import { useKibana } from '../../hooks/use_kibana'; export function ObservabilityAIAssistantActionMenuItem() { const service = useObservabilityAIAssistant(); + const { plugins } = useKibana().services; const [isOpen, setIsOpen] = useState(false); @@ -44,15 +48,15 @@ export function ObservabilityAIAssistantActionMenuItem() { }; }, []); + const { from, to } = plugins.start.data.query.timefilter.timefilter.getTime(); useEffect(() => { - const unregister = service.setScreenContext({ - screenDescription: 'The user is looking at ' + window.location.href, - }); + const start = datemath.parse(from)?.format() ?? moment().subtract(1, 'day').toISOString(); + const end = datemath.parse(to)?.format() ?? moment().toISOString(); - return () => { - unregister(); - }; - }, [service]); + return service.setScreenContext({ + screenDescription: `The user is looking at ${window.location.href}. The current time range is ${start} - ${end}.`, + }); + }, [service, from, to]); if (!service.isEnabled()) { return null; diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/welcome_message.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/welcome_message.tsx index 0227ef42e2808..be6fc3fe77efa 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/welcome_message.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/welcome_message.tsx @@ -87,7 +87,7 @@ export function WelcomeMessage({ className={fullHeightClassName} > - + - +

{i18n.translate('xpack.observabilityAiAssistant.disclaimer.title', { defaultMessage: 'Welcome to the AI Assistant for Observability', diff --git a/x-pack/plugins/observability_ai_assistant/public/types.ts b/x-pack/plugins/observability_ai_assistant/public/types.ts index 3a4c4fbc59d7d..2846a01b049c7 100644 --- a/x-pack/plugins/observability_ai_assistant/public/types.ts +++ b/x-pack/plugins/observability_ai_assistant/public/types.ts @@ -32,6 +32,7 @@ import type { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '@kbn/triggers-actions-ui-plugin/public'; +import { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { StreamingChatResponseEventWithoutError } from '../common/conversation_complete'; import type { ContextDefinition, @@ -112,6 +113,7 @@ export type ChatRegistrationRenderFunction = ({}: { export interface ConfigSchema {} export interface ObservabilityAIAssistantPluginSetupDependencies { + data: DataPublicPluginSetup; dataViews: DataViewsPublicPluginSetup; features: FeaturesPluginSetup; lens: LensPublicSetup; @@ -122,6 +124,7 @@ export interface ObservabilityAIAssistantPluginSetupDependencies { } export interface ObservabilityAIAssistantPluginStartDependencies { + data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; features: FeaturesPluginStart; lens: LensPublicStart; diff --git a/x-pack/plugins/observability_ai_assistant/tsconfig.json b/x-pack/plugins/observability_ai_assistant/tsconfig.json index 13af731fd49db..177e87be3de66 100644 --- a/x-pack/plugins/observability_ai_assistant/tsconfig.json +++ b/x-pack/plugins/observability_ai_assistant/tsconfig.json @@ -68,7 +68,8 @@ "@kbn/visualization-utils", "@kbn/field-types", "@kbn/es-types", - "@kbn/esql-utils" + "@kbn/esql-utils", + "@kbn/data-plugin" ], "exclude": ["target/**/*"] } From f76e7ecef529710c0fd3d4e5dbf21792d2e3bd6c Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Thu, 15 Feb 2024 15:20:20 +0000 Subject: [PATCH 05/23] [INFRA] Add alerts count to hosts data (#176034) Closes https://github.com/elastic/kibana/issues/175567 #### What has been done - create alerts infra client to retrieve alerts data - add endpoint to get the alerts count for hosts - merged alerts count with hosts data - add alertsCount badge to hosts table - sort by alert count by clicking on the column title - If no hosts have active alerts, the column should be hidden. - Default hosts sorting is alerts count and cpu desc https://github.com/elastic/kibana/assets/31922082/96794041-d129-49e2-920c-80b45c624144 --- x-pack/plugins/infra/common/constants.ts | 1 + .../http_api/infra/get_infra_metrics.ts | 15 ++-- .../metrics/hosts/components/tabs/config.ts | 1 - .../hosts/hooks/use_hosts_table.test.ts | 4 + .../metrics/hosts/hooks/use_hosts_table.tsx | 49 ++++++++-- .../hosts/hooks/use_hosts_table_url_state.ts | 4 +- .../pages/metrics/hosts/translations.ts | 4 + .../lib/adapters/framework/adapter_types.ts | 6 +- .../infra/server/routes/infra/index.ts | 13 ++- .../lib/helpers/get_infra_alerts_client.ts | 45 ++++++++++ .../server/routes/infra/lib/host/get_hosts.ts | 20 ++++- .../infra/lib/host/get_hosts_alerts_count.ts | 89 +++++++++++++++++++ .../infra/server/routes/infra/lib/mapper.ts | 22 +++-- .../infra/server/routes/infra/lib/types.ts | 2 + .../api_integration/apis/metrics_ui/infra.ts | 38 +++++++- .../test/functional/apps/infra/hosts_view.ts | 70 ++++++++------- .../page_objects/infra_hosts_view.ts | 31 +++++-- 17 files changed, 351 insertions(+), 63 deletions(-) create mode 100644 x-pack/plugins/infra/server/routes/infra/lib/helpers/get_infra_alerts_client.ts create mode 100644 x-pack/plugins/infra/server/routes/infra/lib/host/get_hosts_alerts_count.ts diff --git a/x-pack/plugins/infra/common/constants.ts b/x-pack/plugins/infra/common/constants.ts index 2a4e9b5e21eae..e4621f63793c2 100644 --- a/x-pack/plugins/infra/common/constants.ts +++ b/x-pack/plugins/infra/common/constants.ts @@ -11,6 +11,7 @@ export const METRICS_APP = 'metrics'; export const LOGS_APP = 'logs'; export const METRICS_FEATURE_ID = 'infrastructure'; +export const INFRA_ALERT_FEATURE_ID = 'infrastructure'; export const LOGS_FEATURE_ID = 'logs'; export type InfraFeatureId = typeof METRICS_FEATURE_ID | typeof LOGS_FEATURE_ID; diff --git a/x-pack/plugins/infra/common/http_api/infra/get_infra_metrics.ts b/x-pack/plugins/infra/common/http_api/infra/get_infra_metrics.ts index f0b0f4eb7f0ab..1c6076b277a55 100644 --- a/x-pack/plugins/infra/common/http_api/infra/get_infra_metrics.ts +++ b/x-pack/plugins/infra/common/http_api/infra/get_infra_metrics.ts @@ -53,11 +53,16 @@ export const GetInfraMetricsRequestBodyPayloadRT = rt.intersection([ }), ]); -export const InfraAssetMetricsItemRT = rt.type({ - name: rt.string, - metrics: rt.array(InfraAssetMetricsRT), - metadata: rt.array(InfraAssetMetadataRT), -}); +export const InfraAssetMetricsItemRT = rt.intersection([ + rt.type({ + name: rt.string, + metrics: rt.array(InfraAssetMetricsRT), + metadata: rt.array(InfraAssetMetadataRT), + }), + rt.partial({ + alertsCount: rt.number, + }), +]); export const GetInfraMetricsResponsePayloadRT = rt.type({ type: rt.literal('host'), diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/config.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/config.ts index cf4374cae94fa..253bb59f79d66 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/config.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/config.ts @@ -11,5 +11,4 @@ import type { ValidFeatureId } from '@kbn/rule-data-utils'; export const ALERTS_PER_PAGE = 10; export const ALERTS_TABLE_ID = 'xpack.infra.hosts.alerts.table'; -export const INFRA_ALERT_FEATURE_ID = 'infrastructure'; export const infraAlertFeatureIds: ValidFeatureId[] = [AlertConsumers.INFRASTRUCTURE]; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts index 1d7ea3bd82538..9534406d5dcfa 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts @@ -72,6 +72,7 @@ const mockHostNode: InfraAssetMetricsItem[] = [ { name: 'cloud.provider', value: 'aws' }, ], name: 'host-0', + alertsCount: 0, }, { metrics: [ @@ -109,6 +110,7 @@ const mockHostNode: InfraAssetMetricsItem[] = [ { name: 'host.ip', value: '243.86.94.22' }, ], name: 'host-1', + alertsCount: 0, }, ]; @@ -161,6 +163,7 @@ describe('useHostTable hook', () => { diskSpaceUsage: 0.2040001, memoryFree: 34359.738368, normalizedLoad1m: 239.2040001, + alertsCount: 0, }, { name: 'host-1', @@ -178,6 +181,7 @@ describe('useHostTable hook', () => { diskSpaceUsage: 0.5400000214576721, memoryFree: 9.194304, normalizedLoad1m: 100, + alertsCount: 0, }, ]; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx index 446a0c577bca9..c54877bd11a20 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx @@ -13,6 +13,8 @@ import { isEqual } from 'lodash'; import { isNumber } from 'lodash/fp'; import { CloudProvider } from '@kbn/custom-icons'; import { findInventoryModel } from '@kbn/metrics-data-access-plugin/common'; +import { EuiToolTip } from '@elastic/eui'; +import { EuiBadge } from '@elastic/eui'; import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana'; import { createInventoryMetricFormatter } from '../../inventory_view/lib/create_inventory_metric_formatter'; import { EntryTitle } from '../components/table/entry_title'; @@ -44,6 +46,7 @@ interface HostMetadata { export type HostNodeRow = HostMetadata & HostMetrics & { name: string; + alertsCount?: number; }; /** @@ -54,7 +57,7 @@ const formatMetric = (type: InfraAssetMetricType, value: number | undefined | nu }; const buildItemsList = (nodes: InfraAssetMetricsItem[]): HostNodeRow[] => { - return nodes.map(({ metrics, metadata, name }) => { + return nodes.map(({ metrics, metadata, name, alertsCount }) => { const metadataKeyValue = metadata.reduce( (acc, curr) => ({ ...acc, @@ -79,12 +82,14 @@ const buildItemsList = (nodes: InfraAssetMetricsItem[]): HostNodeRow[] => { }), {} as HostMetrics ), + + alertsCount: alertsCount ?? 0, }; }); }; -const isTitleColumn = (cell: any): cell is HostNodeRow['title'] => { - return typeof cell === 'object' && cell && 'name' in cell; +const isTitleColumn = (cell: HostNodeRow[keyof HostNodeRow]): cell is HostNodeRow['title'] => { + return cell !== null && typeof cell === 'object' && cell && 'name' in cell; }; const sortValues = (aValue: any, bValue: any, { direction }: Sorting) => { @@ -124,6 +129,8 @@ export const useHostsTable = () => { const [selectedItems, setSelectedItems] = useState([]); const { hostNodes } = useHostsViewContext(); + const displayAlerts = hostNodes.some((item) => 'alertsCount' in item); + const { value: formulas } = useAsync(() => inventoryModel.metrics.getFormulas()); const [{ detailsItemId, pagination, sorting }, setProperties] = useHostsTableUrlState(); @@ -221,6 +228,39 @@ export const useHostsTable = () => { }, ], }, + ...(displayAlerts + ? [ + { + name: TABLE_COLUMN_LABEL.alertsCount, + field: 'alertsCount', + sortable: true, + 'data-test-subj': 'hostsView-tableRow-alertsCount', + render: (alertsCount: HostNodeRow['alertsCount'], row: HostNodeRow) => { + if (!alertsCount) { + return null; + } + return ( + + { + setProperties({ detailsItemId: row.id === detailsItemId ? null : row.id }); + }} + onClickAriaLabel={TABLE_COLUMN_LABEL.alertsCount} + iconOnClick={() => { + setProperties({ detailsItemId: row.id === detailsItemId ? null : row.id }); + }} + iconOnClickAriaLabel={TABLE_COLUMN_LABEL.alertsCount} + > + {alertsCount} + + + ); + }, + }, + ] + : []), { name: TABLE_COLUMN_LABEL.title, field: 'title', @@ -315,7 +355,6 @@ export const useHostsTable = () => { 'data-test-subj': 'hostsView-tableRow-rx', render: (avg: number) => formatMetric('rx', avg), align: 'right', - width: '120px', }, { name: ( @@ -330,7 +369,6 @@ export const useHostsTable = () => { 'data-test-subj': 'hostsView-tableRow-tx', render: (avg: number) => formatMetric('tx', avg), align: 'right', - width: '120px', }, ], [ @@ -344,6 +382,7 @@ export const useHostsTable = () => { formulas?.tx.value, reportHostEntryClick, setProperties, + displayAlerts, ] ); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table_url_state.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table_url_state.ts index c1dcafefaccc3..dd53bd96dc185 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table_url_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table_url_state.ts @@ -18,8 +18,8 @@ import { DEFAULT_PAGE_SIZE, LOCAL_STORAGE_PAGE_SIZE_KEY } from '../constants'; export const GET_DEFAULT_TABLE_PROPERTIES: TableProperties = { detailsItemId: null, sorting: { - direction: 'asc', - field: 'name', + direction: 'desc', + field: 'alertsCount', }, pagination: { pageIndex: 0, diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/translations.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/translations.ts index 304b1980d965c..9be99e1d70a8b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/translations.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/translations.ts @@ -8,6 +8,10 @@ import { i18n } from '@kbn/i18n'; export const TABLE_COLUMN_LABEL = { + alertsCount: i18n.translate('xpack.infra.hostsViewPage.table.alertsColumnHeader', { + defaultMessage: 'Active alerts', + }), + title: i18n.translate('xpack.infra.hostsViewPage.table.nameColumnHeader', { defaultMessage: 'Name', }), diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts index 56b8fe29c85d6..398eaa0ccf3b8 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts @@ -22,7 +22,10 @@ import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin import { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import { PluginSetupContract as AlertingPluginContract } from '@kbn/alerting-plugin/server'; import { MlPluginSetup } from '@kbn/ml-plugin/server'; -import { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server'; +import { + RuleRegistryPluginSetupContract, + RuleRegistryPluginStartContract, +} from '@kbn/rule-registry-plugin/server'; import { ObservabilityPluginSetup } from '@kbn/observability-plugin/server'; import { LogsSharedPluginSetup, LogsSharedPluginStart } from '@kbn/logs-shared-plugin/server'; import { VersionedRouteConfig } from '@kbn/core-http-server'; @@ -59,6 +62,7 @@ export interface InfraServerPluginStartDeps { dataViews: DataViewsPluginStart; logsShared: LogsSharedPluginStart; profilingDataAccess?: ProfilingDataAccessPluginStart; + ruleRegistry: RuleRegistryPluginStartContract; apmDataAccess: ApmDataAccessPluginStart; } diff --git a/x-pack/plugins/infra/server/routes/infra/index.ts b/x-pack/plugins/infra/server/routes/infra/index.ts index d98e8034e6207..4d8fd931082c1 100644 --- a/x-pack/plugins/infra/server/routes/infra/index.ts +++ b/x-pack/plugins/infra/server/routes/infra/index.ts @@ -14,6 +14,7 @@ import { GetInfraMetricsResponsePayloadRT, } from '../../../common/http_api/infra'; import { InfraBackendLibs } from '../../lib/infra_types'; +import { getInfraAlertsClient } from './lib/helpers/get_infra_alerts_client'; import { getHosts } from './lib/host/get_hosts'; export const initInfraMetricsRoute = (libs: InfraBackendLibs) => { @@ -35,10 +36,20 @@ export const initInfraMetricsRoute = (libs: InfraBackendLibs) => { try { const searchClient = data.search.asScoped(request); + const alertsClient = await getInfraAlertsClient({ + getStartServices: libs.getStartServices, + request, + }); const soClient = savedObjects.getScopedClient(request); const source = await libs.sources.getSourceConfiguration(soClient, params.sourceId); - const hosts = await getHosts({ searchClient, sourceConfig: source.configuration, params }); + const hosts = await getHosts({ + searchClient, + alertsClient, + sourceConfig: source.configuration, + params, + }); + return response.ok({ body: GetInfraMetricsResponsePayloadRT.encode(hosts), }); diff --git a/x-pack/plugins/infra/server/routes/infra/lib/helpers/get_infra_alerts_client.ts b/x-pack/plugins/infra/server/routes/infra/lib/helpers/get_infra_alerts_client.ts new file mode 100644 index 0000000000000..7110a822fb8a4 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/infra/lib/helpers/get_infra_alerts_client.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isEmpty } from 'lodash'; +import { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; +import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common'; +import { KibanaRequest } from '@kbn/core/server'; +import type { InfraPluginStartServicesAccessor } from '../../../../types'; + +type RequiredParams = ESSearchRequest & { + size: number; + track_total_hits: boolean | number; +}; + +export type InfraAlertsClient = Awaited>; + +export async function getInfraAlertsClient({ + getStartServices, + request, +}: { + getStartServices: InfraPluginStartServicesAccessor; + request: KibanaRequest; +}) { + const [, { ruleRegistry }] = await getStartServices(); + const alertsClient = await ruleRegistry.getRacClientWithRequest(request); + const infraAlertsIndices = await alertsClient.getAuthorizedAlertsIndices(['infrastructure']); + + if (!infraAlertsIndices || isEmpty(infraAlertsIndices)) { + throw Error('No alert indices exist for "infrastrucuture"'); + } + + return { + search( + searchParams: TParams + ): Promise> { + return alertsClient.find({ + ...searchParams, + index: infraAlertsIndices.join(','), + }) as Promise; + }, + }; +} diff --git a/x-pack/plugins/infra/server/routes/infra/lib/host/get_hosts.ts b/x-pack/plugins/infra/server/routes/infra/lib/host/get_hosts.ts index 6d44224661750..6eab207f7fbae 100644 --- a/x-pack/plugins/infra/server/routes/infra/lib/host/get_hosts.ts +++ b/x-pack/plugins/infra/server/routes/infra/lib/host/get_hosts.ts @@ -11,6 +11,7 @@ import { mapToApiResponse } from '../mapper'; import { hasFilters } from '../utils'; import { GetHostsArgs } from '../types'; import { getAllHosts } from './get_all_hosts'; +import { getHostsAlertsCount } from './get_hosts_alerts_count'; export const getHosts = async (args: GetHostsArgs): Promise => { const runFilterQuery = hasFilters(args.params.query); @@ -23,8 +24,23 @@ export const getHosts = async (args: GetHostsArgs): Promise { diff --git a/x-pack/plugins/infra/server/routes/infra/lib/host/get_hosts_alerts_count.ts b/x-pack/plugins/infra/server/routes/infra/lib/host/get_hosts_alerts_count.ts new file mode 100644 index 0000000000000..d1e17e8d133ee --- /dev/null +++ b/x-pack/plugins/infra/server/routes/infra/lib/host/get_hosts_alerts_count.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kqlQuery, termQuery, termsQuery } from '@kbn/observability-plugin/server'; +import { + ALERT_RULE_PRODUCER, + ALERT_STATUS, + ALERT_STATUS_ACTIVE, + ALERT_UUID, +} from '@kbn/rule-data-utils'; +import { INFRA_ALERT_FEATURE_ID } from '../../../../../common/constants'; +import { BUCKET_KEY, MAX_SIZE } from '../constants'; +import { InfraAlertsClient } from '../helpers/get_infra_alerts_client'; + +export type HostAlertsResponse = Array<{ + name: string; + alertsCount: number; +}>; + +export async function getHostsAlertsCount({ + alertsClient, + hostNamesShortList, + kuery, + from, + to, + maxNumHosts = MAX_SIZE, +}: { + alertsClient: InfraAlertsClient; + hostNamesShortList: string[]; + kuery?: string; + from: string; + to: string; + maxNumHosts?: number; +}): Promise { + const rangeQuery = [ + { + range: { + 'kibana.alert.time_range': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + const params = { + size: 0, + track_total_hits: false, + query: { + bool: { + filter: [ + ...termQuery(ALERT_RULE_PRODUCER, INFRA_ALERT_FEATURE_ID), + ...termQuery(ALERT_STATUS, ALERT_STATUS_ACTIVE), + ...termsQuery(BUCKET_KEY, ...hostNamesShortList), + ...rangeQuery, + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + hosts: { + terms: { + field: BUCKET_KEY, + size: maxNumHosts, + }, + aggs: { + alerts_count: { + cardinality: { + field: ALERT_UUID, + }, + }, + }, + }, + }, + }; + + const result = await alertsClient.search(params); + + const filterAggBuckets = result.aggregations?.hosts.buckets ?? []; + + return filterAggBuckets.map((bucket) => ({ + name: bucket.key as string, + alertsCount: bucket.alerts_count.value, + })); +} diff --git a/x-pack/plugins/infra/server/routes/infra/lib/mapper.ts b/x-pack/plugins/infra/server/routes/infra/lib/mapper.ts index 89d2e71b364f9..cc922d42c8991 100644 --- a/x-pack/plugins/infra/server/routes/infra/lib/mapper.ts +++ b/x-pack/plugins/infra/server/routes/infra/lib/mapper.ts @@ -20,10 +20,12 @@ import { HostsMetricsSearchValueRT, } from './types'; import { METADATA_AGGREGATION_NAME } from './constants'; +import { HostAlertsResponse } from './host/get_hosts_alerts_count'; export const mapToApiResponse = ( params: GetInfraMetricsRequestBodyPayload, - buckets?: HostsMetricsSearchBucket[] | undefined + buckets?: HostsMetricsSearchBucket[] | undefined, + alertsCountResponse?: HostAlertsResponse ): GetInfraMetricsResponsePayload => { if (!buckets) { return { @@ -32,12 +34,20 @@ export const mapToApiResponse = ( }; } - const hosts = buckets.map((bucket) => { - const metrics = convertMetricBucket(params, bucket); - const metadata = convertMetadataBucket(bucket); + const hosts = buckets + .map((bucket) => { + const metrics = convertMetricBucket(params, bucket); + const metadata = convertMetadataBucket(bucket); - return { name: bucket.key as string, metrics, metadata }; - }); + const cpuValue = metrics.find((metric) => metric.name === 'cpu')?.value ?? 0; + const alerts = alertsCountResponse?.find((item) => item.name === bucket.key); + + return { name: bucket.key as string, metrics, metadata, cpuValue, ...alerts }; + }) + .sort((a, b) => { + return b.cpuValue - a.cpuValue; + }) + .map(({ cpuValue, ...rest }) => rest); return { type: params.type, diff --git a/x-pack/plugins/infra/server/routes/infra/lib/types.ts b/x-pack/plugins/infra/server/routes/infra/lib/types.ts index d9800112e7dfe..ab097e91b2b09 100644 --- a/x-pack/plugins/infra/server/routes/infra/lib/types.ts +++ b/x-pack/plugins/infra/server/routes/infra/lib/types.ts @@ -12,6 +12,7 @@ import { InfraStaticSourceConfiguration } from '../../../../common/source_config import { GetInfraMetricsRequestBodyPayload } from '../../../../common/http_api/infra'; import { BasicMetricValueRT, TopMetricsTypeRT } from '../../../lib/metrics/types'; +import { InfraAlertsClient } from './helpers/get_infra_alerts_client'; export const FilteredMetricsTypeRT = rt.type({ doc_count: rt.number, @@ -76,6 +77,7 @@ export interface HostsMetricsAggregationQueryConfig { export interface GetHostsArgs { searchClient: ISearchClient; + alertsClient: InfraAlertsClient; sourceConfig: InfraStaticSourceConfiguration; params: GetInfraMetricsRequestBodyPayload; } diff --git a/x-pack/test/api_integration/apis/metrics_ui/infra.ts b/x-pack/test/api_integration/apis/metrics_ui/infra.ts index baa907c25575f..c3465c68ae7fe 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/infra.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/infra.ts @@ -165,8 +165,8 @@ export default function ({ getService }: FtrProviderContext) { const names = (response.body as GetInfraMetricsResponsePayload).nodes.map((p) => p.name); expect(names).eql([ - 'gke-observability-8--observability-8--bc1afd95-f0zc', 'gke-observability-8--observability-8--bc1afd95-ngmh', + 'gke-observability-8--observability-8--bc1afd95-f0zc', 'gke-observability-8--observability-8--bc1afd95-nhhw', ]); }); @@ -208,8 +208,9 @@ export default function ({ getService }: FtrProviderContext) { const names = (response.body as GetInfraMetricsResponsePayload).nodes.map((p) => p.name); expect(names).eql([ - 'gke-observability-8--observability-8--bc1afd95-f0zc', 'gke-observability-8--observability-8--bc1afd95-ngmh', + 'gke-observability-8--observability-8--bc1afd95-f0zc', + , ]); }); @@ -269,5 +270,38 @@ export default function ({ getService }: FtrProviderContext) { ); }); }); + + describe('Host with active alerts', () => { + before(async () => { + await Promise.all([ + esArchiver.load('x-pack/test/functional/es_archives/infra/alerts'), + esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'), + ]); + }); + + after(async () => { + await Promise.all([ + esArchiver.unload('x-pack/test/functional/es_archives/infra/alerts'), + esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'), + ]); + }); + + describe('fetch hosts', () => { + it('should return metrics for a host with alert count', async () => { + const body: GetInfraMetricsRequestBodyPayload = { + ...basePayload, + range: { + from: '2018-10-17T19:42:21.208Z', + to: '2018-10-17T19:58:03.952Z', + }, + limit: 1, + }; + const response = await makeRequest({ body, expectedHTTPCode: 200 }); + + expect(response.body.nodes).length(1); + expect(response.body.nodes[0].alertsCount).eql(2); + }); + }); + }); }); } diff --git a/x-pack/test/functional/apps/infra/hosts_view.ts b/x-pack/test/functional/apps/infra/hosts_view.ts index 260b48c26f0fc..a185023c9b33d 100644 --- a/x-pack/test/functional/apps/infra/hosts_view.ts +++ b/x-pack/test/functional/apps/infra/hosts_view.ts @@ -34,6 +34,7 @@ const END_HOST_PROCESSES_DATE = moment.utc(DATES.metricsAndLogs.hosts.processesD const tableEntries = [ { + alertsCount: 2, title: 'demo-stack-apache-01', cpuUsage: '1.2%', normalizedLoad: '0.5%', @@ -44,36 +45,29 @@ const tableEntries = [ tx: '0 bit/s', }, { - title: 'demo-stack-client-01', - cpuUsage: '0.5%', - normalizedLoad: '0.1%', - memoryUsage: '13.8%', - memoryFree: '3.3 GB', - diskSpaceUsage: '16.9%', - rx: '0 bit/s', - tx: '0 bit/s', - }, - { - title: 'demo-stack-haproxy-01', - cpuUsage: '0.8%', + alertsCount: 2, + title: 'demo-stack-mysql-01', + cpuUsage: '0.9%', normalizedLoad: '0%', - memoryUsage: '16.5%', + memoryUsage: '18.2%', memoryFree: '3.2 GB', - diskSpaceUsage: '16.3%', + diskSpaceUsage: '17.8%', rx: '0 bit/s', tx: '0 bit/s', }, { - title: 'demo-stack-mysql-01', - cpuUsage: '0.9%', + alertsCount: 2, + title: 'demo-stack-redis-01', + cpuUsage: '0.8%', normalizedLoad: '0%', - memoryUsage: '18.2%', - memoryFree: '3.2 GB', - diskSpaceUsage: '17.8%', + memoryUsage: '15.9%', + memoryFree: '3.3 GB', + diskSpaceUsage: '16.3%', rx: '0 bit/s', tx: '0 bit/s', }, { + alertsCount: 0, title: 'demo-stack-nginx-01', cpuUsage: '0.8%', normalizedLoad: '1.4%', @@ -84,15 +78,27 @@ const tableEntries = [ tx: '0 bit/s', }, { - title: 'demo-stack-redis-01', + alertsCount: 0, + title: 'demo-stack-haproxy-01', cpuUsage: '0.8%', normalizedLoad: '0%', - memoryUsage: '15.9%', - memoryFree: '3.3 GB', + memoryUsage: '16.5%', + memoryFree: '3.2 GB', diskSpaceUsage: '16.3%', rx: '0 bit/s', tx: '0 bit/s', }, + { + alertsCount: 0, + title: 'demo-stack-client-01', + cpuUsage: '0.5%', + normalizedLoad: '0.1%', + memoryUsage: '13.8%', + memoryFree: '3.3 GB', + diskSpaceUsage: '16.9%', + rx: '0 bit/s', + tx: '0 bit/s', + }, ]; export default ({ getPageObjects, getService }: FtrProviderContext) => { @@ -610,10 +616,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await Promise.all( [ { metric: 'hostsCount', value: '3' }, - { metric: 'cpuUsage', value: '0.8%' }, + { metric: 'cpuUsage', value: '0.9%' }, { metric: 'normalizedLoad1m', value: '0.2%' }, - { metric: 'memoryUsage', value: '16.3%' }, - { metric: 'diskUsage', value: '16.9%' }, + { metric: 'memoryUsage', value: '17.5%' }, + { metric: 'diskUsage', value: '17.2%' }, ].map(async ({ metric, value }) => { await retry.try(async () => { const tileValue = await pageObjects.infraHostsView.getKPITileValue(metric); @@ -626,12 +632,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should update the alerts count on a search submit', async () => { const alertsCount = await pageObjects.infraHostsView.getAlertsCount(); - expect(alertsCount).to.be('2'); + expect(alertsCount).to.be('6'); }); it('should update the alerts table content on a search submit', async () => { - const ACTIVE_ALERTS = 2; - const RECOVERED_ALERTS = 2; + const ACTIVE_ALERTS = 6; + const RECOVERED_ALERTS = 4; const ALL_ALERTS = ACTIVE_ALERTS + RECOVERED_ALERTS; const COLUMNS = 11; @@ -708,7 +714,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.infraHostsView.sortByCpuUsage(); let hostRows = await pageObjects.infraHostsView.getHostsTableData(); const hostDataFirtPage = await pageObjects.infraHostsView.getHostsRowData(hostRows[0]); - expect(hostDataFirtPage).to.eql(tableEntries[1]); + expect(hostDataFirtPage).to.eql(tableEntries[5]); await pageObjects.infraHostsView.paginateTo(2); hostRows = await pageObjects.infraHostsView.getHostsTableData(); @@ -725,7 +731,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.infraHostsView.paginateTo(2); hostRows = await pageObjects.infraHostsView.getHostsTableData(); const hostDataLastPage = await pageObjects.infraHostsView.getHostsRowData(hostRows[0]); - expect(hostDataLastPage).to.eql(tableEntries[1]); + expect(hostDataLastPage).to.eql(tableEntries[5]); }); it('should sort by text field asc', async () => { @@ -737,14 +743,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.infraHostsView.paginateTo(2); hostRows = await pageObjects.infraHostsView.getHostsTableData(); const hostDataLastPage = await pageObjects.infraHostsView.getHostsRowData(hostRows[0]); - expect(hostDataLastPage).to.eql(tableEntries[5]); + expect(hostDataLastPage).to.eql(tableEntries[2]); }); it('should sort by text field desc', async () => { await pageObjects.infraHostsView.sortByTitle(); let hostRows = await pageObjects.infraHostsView.getHostsTableData(); const hostDataFirtPage = await pageObjects.infraHostsView.getHostsRowData(hostRows[0]); - expect(hostDataFirtPage).to.eql(tableEntries[5]); + expect(hostDataFirtPage).to.eql(tableEntries[2]); await pageObjects.infraHostsView.paginateTo(2); hostRows = await pageObjects.infraHostsView.getHostsTableData(); diff --git a/x-pack/test/functional/page_objects/infra_hosts_view.ts b/x-pack/test/functional/page_objects/infra_hosts_view.ts index 60c4f0727e7c0..31ae75c4000d6 100644 --- a/x-pack/test/functional/page_objects/infra_hosts_view.ts +++ b/x-pack/test/functional/page_objects/infra_hosts_view.ts @@ -66,10 +66,29 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) { const cells = await row.findAllByCssSelector('[data-test-subj*="hostsView-tableRow-"]'); // Retrieve content for each cell - const [title, cpuUsage, normalizedLoad, memoryUsage, memoryFree, diskSpaceUsage, rx, tx] = - await Promise.all(cells.map((cell) => this.getHostsCellContent(cell))); - - return { title, cpuUsage, normalizedLoad, memoryUsage, memoryFree, diskSpaceUsage, rx, tx }; + const [ + alertsCount, + title, + cpuUsage, + normalizedLoad, + memoryUsage, + memoryFree, + diskSpaceUsage, + rx, + tx, + ] = await Promise.all(cells.map((cell) => this.getHostsCellContent(cell))); + + return { + alertsCount, + title, + cpuUsage, + normalizedLoad, + memoryUsage, + memoryFree, + diskSpaceUsage, + rx, + tx, + }; }, async getHostsCellContent(cell: WebElementWrapper) { @@ -235,11 +254,11 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) { // Sorting getCpuUsageHeader() { - return testSubjects.find('tableHeaderCell_cpu_2'); + return testSubjects.find('tableHeaderCell_cpu_3'); }, getTitleHeader() { - return testSubjects.find('tableHeaderCell_title_1'); + return testSubjects.find('tableHeaderCell_title_2'); }, async sortByCpuUsage() { From 07b910f02c3d34174ee15bb745fbc897975cd464 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:01:44 -0500 Subject: [PATCH 06/23] [Security Solution] Update mappings script to be able to be run directly and from yarn again (#176880) ## Summary This pr https://github.com/elastic/kibana/pull/168047 broke the mappings script so that it can only be run directly, and not from package.json. This pr changes it so that the script works in both scenarios. --- .../scripts/mappings/mappings_loader_script.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/scripts/mappings/mappings_loader_script.ts b/x-pack/plugins/security_solution/scripts/mappings/mappings_loader_script.ts index 7997401f35e70..d8767d21a677c 100644 --- a/x-pack/plugins/security_solution/scripts/mappings/mappings_loader_script.ts +++ b/x-pack/plugins/security_solution/scripts/mappings/mappings_loader_script.ts @@ -11,7 +11,14 @@ import path from 'path'; import yargs from 'yargs'; import { execSync } from 'child_process'; -const CONFIG_PATH = '../../../../../test/functional/config.base.js'; +const requireMain = require.main; +let appDir = process.cwd(); +if (requireMain) { + appDir = path.dirname(requireMain.filename); +} + +const CONFIG_PATH = path.resolve(appDir, '../../../../../test/functional/config.base.js'); +const ES_ARCHIVER_PATH = path.resolve(appDir, '../../../../../scripts/es_archiver'); const loadAllIndices = (esUrl: string, kibanaUrl: string, mappingsDir: string) => { const exec = (cmd: string) => execSync(cmd, { stdio: 'inherit' }); @@ -40,7 +47,7 @@ const loadAllIndices = (esUrl: string, kibanaUrl: string, mappingsDir: string) = return; } exec( - `node ../../../../../scripts/es_archiver load ${fullPath} --config "${CONFIG_PATH}" --es-url=${esUrl} --kibana-url=${kibanaUrl}` + `node ${ES_ARCHIVER_PATH} load ${fullPath} --config "${CONFIG_PATH}" --es-url=${esUrl} --kibana-url=${kibanaUrl}` ); }); }); From 2ebd8dd83ef8a6863bd84ce2435cfc42105b8de6 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Thu, 15 Feb 2024 17:03:40 +0100 Subject: [PATCH 07/23] Make sure container doesn't overflow (#177024) ## Summary Fixes an issue where due to flexbox'es fantastic API every container needs a `min-width` set, otherwise it won't honor `width: 100%` when a child element overflows. Also cleans up some stuff. --- .../public/components/chat/chat_body.tsx | 1 + .../public/components/page_template.tsx | 1 + .../conversations/conversation_view.tsx | 126 +++++++++--------- 3 files changed, 64 insertions(+), 64 deletions(-) diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx index a89419c366a2d..83eae35e59f97 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_body.tsx @@ -154,6 +154,7 @@ export function ChatBody({ } const containerClassName = css` + min-width: 0; max-height: 100%; max-width: ${startedFrom === 'conversationView' ? 1200 - 250 + 'px' // page template max width - conversation list width. diff --git a/x-pack/plugins/observability_ai_assistant/public/components/page_template.tsx b/x-pack/plugins/observability_ai_assistant/public/components/page_template.tsx index 94c36f463aaf4..7cab8d586c83e 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/page_template.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/page_template.tsx @@ -36,6 +36,7 @@ export function ObservabilityAIAssistantPageTemplate({ children }: { children: R contentProps: { className: pageSectionContentClassName, }, + paddingSize: 'none', }} > {children} diff --git a/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx b/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx index 0f49389c1c60d..91f884dbd84c9 100644 --- a/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/routes/conversations/conversation_view.tsx @@ -24,10 +24,6 @@ import { useObservabilityAIAssistantParams } from '../../hooks/use_observability import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router'; import { ChatInlineEditingContent } from '../../components/chat/chat_inline_edit'; -const containerClassName = css` - max-width: 100%; -`; - const SECOND_SLOT_CONTAINER_WIDTH = 400; export function ConversationView() { @@ -120,6 +116,10 @@ export function ConversationView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const containerClassName = css` + max-width: 100%; + `; + const conversationListContainerName = css` min-width: 250px; width: 250px; @@ -150,67 +150,65 @@ export function ConversationView() { `; return ( - <> - - - { - if (conversationId) { - observabilityAIAssistantRouter.push('/conversations/new', { - path: {}, - query: {}, - }); - } else { - // clear the chat - chatBodyKeyRef.current = v4(); - forceUpdate(); - } - }} - onClickChat={(id) => { - navigateToConversation(id, false); - }} - onClickDeleteConversation={(id) => { - if (conversationId === id) { - navigateToConversation(undefined, false); - } - }} + + + { + if (conversationId) { + observabilityAIAssistantRouter.push('/conversations/new', { + path: {}, + query: {}, + }); + } else { + // clear the chat + chatBodyKeyRef.current = v4(); + forceUpdate(); + } + }} + onClickChat={(id) => { + navigateToConversation(id, false); + }} + onClickDeleteConversation={(id) => { + if (conversationId === id) { + navigateToConversation(undefined, false); + } + }} + /> + + + + {!chatService.value ? ( + + + + + + + ) : null} + + {chatService.value && ( + + - - - - {!chatService.value ? ( - - - - - - - ) : null} - - {chatService.value && ( - - -
- -
-
- )} -
- +
+ +
+ + )} + ); } From 1c2321e1018f259a2eeb811a8f5a7141115f339f Mon Sep 17 00:00:00 2001 From: Marco Antonio Ghiani Date: Thu, 15 Feb 2024 17:06:15 +0100 Subject: [PATCH 08/23] [Logs Explorer] Expose customization events (#176825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📓 Summary This work is a follow-up of the newly introduced [support to logs backed data views](https://github.com/elastic/kibana/pull/176078) in Logs Explorer. In [a comment](https://github.com/elastic/kibana/pull/176078#discussion_r1486811951) from the PR mentioned above, we discussed delegating to the consumers the responsibility to redirect to discover when selecting a non-logs data view, to prevent hard-coding a page-wide side-effect of navigating to a different URL. This introduces a new customization interface for the LogsExplorer that controls specific actions, starting from the first added event `onUknownDataViewSelection`. In case the consumers of this component do not provide the event handler, the data view entry in the data source selector will appear disabled and will not be clickable. Screenshot 2024-02-13 at 15 45 30 ## Example When creating the controller to pass into the LogsExplorer component, we can specify the event for handling the discover navigation as follow: ```ts createLogsExplorerController({ customizations: { events: { onUknownDataViewSelection: (context) => { /* ... */ }, }, }, }); ``` A use case for such usage is, for instance, that some consumers might want to prompt the user somehow before performing the navigation, or simply they don't want to do any navigation. --------- Co-authored-by: Marco Antonio Ghiani --- .../logs_explorer/common/index.ts | 3 ++ .../dataset_selector/dataset_selector.tsx | 11 ++++++- .../sub_components/data_view_menu_item.tsx | 2 +- .../components/dataset_selector/types.ts | 9 ++++- .../public/controller/create_controller.ts | 5 +-- .../custom_dataset_selector.tsx | 10 +++++- .../customizations/logs_explorer_profile.tsx | 1 + .../public/customizations/types.ts | 8 +++++ .../public/hooks/use_data_views.tsx | 17 ++++++++-- .../logs_explorer/public/index.ts | 1 + .../src/services/discover_service.ts | 33 ++++--------------- .../src/services/selection_service.ts | 12 +++---- .../src/state_machine.ts | 15 +++++---- .../discover_navigation_handler.ts | 30 +++++++++++++++++ .../logs_explorer_customizations/index.ts | 10 +++++- .../public/routes/main/main_route.tsx | 8 +++-- 16 files changed, 125 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugins/observability_solution/observability_logs_explorer/public/logs_explorer_customizations/discover_navigation_handler.ts diff --git a/x-pack/plugins/observability_solution/logs_explorer/common/index.ts b/x-pack/plugins/observability_solution/logs_explorer/common/index.ts index 0f593cb8ad072..9eb381441cc55 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/common/index.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/common/index.ts @@ -16,6 +16,9 @@ export { AllDatasetSelection, datasetSelectionPlainRT, hydrateDatasetSelection, + isDatasetSelection, + isDataViewSelection, + isUnresolvedDatasetSelection, UnresolvedDatasetSelection, } from './dataset_selection'; export type { DatasetSelectionPlain } from './dataset_selection'; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/dataset_selector.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/dataset_selector.tsx index 507f4d83f8957..36b9e337a5a90 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/dataset_selector.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/dataset_selector.tsx @@ -44,6 +44,7 @@ export function DatasetSelector({ discoverEsqlUrlProps, integrations, integrationsError, + isDataViewAvailable, isEsqlEnabled, isLoadingDataViews, isLoadingIntegrations, @@ -164,8 +165,16 @@ export function DatasetSelector({ 'data-test-subj': getDataViewTestSubj(dataView.title), name: , onClick: () => selectDataView(dataView), + disabled: !isDataViewAvailable(dataView), })); - }, [dataViews, dataViewsError, isLoadingDataViews, selectDataView, onDataViewsReload]); + }, [ + dataViews, + dataViewsError, + isDataViewAvailable, + isLoadingDataViews, + onDataViewsReload, + selectDataView, + ]); const tabs = [ { diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/sub_components/data_view_menu_item.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/sub_components/data_view_menu_item.tsx index 04388f4f479ec..a260574a28e03 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/sub_components/data_view_menu_item.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/sub_components/data_view_menu_item.tsx @@ -21,7 +21,7 @@ const rightSpacing = css` `; export const DataViewMenuItem = ({ dataView }: DataViewMenuItemProps) => { - if (dataView.dataType === 'logs') { + if (dataView.isLogsDataType()) { return {dataView.name}; } diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/types.ts b/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/types.ts index 5076f382f2fcb..e2572f4543533 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/types.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/dataset_selector/types.ts @@ -26,7 +26,12 @@ import { INTEGRATIONS_TAB_ID, UNCATEGORIZED_TAB_ID, } from './constants'; -import { LoadDataViews, ReloadDataViews, SearchDataViews } from '../../hooks/use_data_views'; +import { + IsDataViewAvailable, + LoadDataViews, + ReloadDataViews, + SearchDataViews, +} from '../../hooks/use_data_views'; import { DiscoverEsqlUrlProps } from '../../hooks/use_esql'; export interface DatasetSelectorProps { @@ -53,6 +58,8 @@ export interface DatasetSelectorProps { isSearchingIntegrations: boolean; /* Flag for determining whether ESQL is enabled or not */ isEsqlEnabled: boolean; + /* Used against a data view to assert its availability */ + isDataViewAvailable: IsDataViewAvailable; /* Triggered when retrying to load the data views */ onDataViewsReload: ReloadDataViews; /* Triggered when the data views tab is selected */ diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/controller/create_controller.ts b/x-pack/plugins/observability_solution/logs_explorer/public/controller/create_controller.ts index 59aad01a0b388..daaa5b27029c6 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/controller/create_controller.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/controller/create_controller.ts @@ -43,7 +43,7 @@ export const createLogsExplorerControllerFactory = customizations?: LogsExplorerCustomizations; initialState?: InitialState; }): Promise => { - const { data, dataViews, discover } = plugins; + const { data, dataViews } = plugins; const datasetsClient = new DatasetsService().start({ http: core.http, @@ -70,7 +70,8 @@ export const createLogsExplorerControllerFactory = const machine = createLogsExplorerControllerStateMachine({ datasetsClient, - plugins: { dataViews, discover }, + dataViews, + events: customizations.events, initialContext, query: discoverServices.data.query, toasts: core.notifications.toasts, diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/customizations/custom_dataset_selector.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/customizations/custom_dataset_selector.tsx index b298a2363a983..4623d73852240 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/customizations/custom_dataset_selector.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/customizations/custom_dataset_selector.tsx @@ -8,6 +8,7 @@ import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import React from 'react'; import { DatasetSelector } from '../components/dataset_selector'; +import { LogsExplorerController } from '../controller'; import { DatasetsProvider, useDatasetsContext } from '../hooks/use_datasets'; import { useDatasetSelection } from '../hooks/use_dataset_selection'; import { DataViewsProvider, useDataViewsContext } from '../hooks/use_data_views'; @@ -52,6 +53,7 @@ export const CustomDatasetSelector = withProviders(({ logsExplorerControllerStat dataViews, error: dataViewsError, isLoading: isLoadingDataViews, + isDataViewAvailable, loadDataViews, reloadDataViews, searchDataViews, @@ -68,6 +70,7 @@ export const CustomDatasetSelector = withProviders(({ logsExplorerControllerStat dataViews={dataViews} dataViewsError={dataViewsError} discoverEsqlUrlProps={discoverEsqlUrlProps} + isDataViewAvailable={isDataViewAvailable} integrations={integrations} integrationsError={integrationsError} isEsqlEnabled={isEsqlEnabled} @@ -98,12 +101,14 @@ export const CustomDatasetSelector = withProviders(({ logsExplorerControllerStat export default CustomDatasetSelector; export type CustomDatasetSelectorBuilderProps = CustomDatasetSelectorProps & { + controller: LogsExplorerController; datasetsClient: IDatasetsClient; dataViews: DataViewsPublicPluginStart; }; function withProviders(Component: React.FunctionComponent) { return function ComponentWithProviders({ + controller, datasetsClient, dataViews, logsExplorerControllerStateService, @@ -111,7 +116,10 @@ function withProviders(Component: React.FunctionComponent - + diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/customizations/logs_explorer_profile.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/customizations/logs_explorer_profile.tsx index ac5eb2d6d81c6..e00bbfa03b7d5 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/customizations/logs_explorer_profile.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/customizations/logs_explorer_profile.tsx @@ -64,6 +64,7 @@ export const createLogsExplorerProfileCustomizations = return ( = (props: Props) => React.ReactNode; @@ -25,8 +26,15 @@ export interface LogsExplorerFlyoutContentProps { doc: LogDocument; } +export type OnUknownDataViewSelectionHandler = (context: LogsExplorerControllerContext) => void; + +export interface LogsExplorerCustomizationEvents { + onUknownDataViewSelection?: OnUknownDataViewSelectionHandler; +} + export interface LogsExplorerCustomizations { flyout?: { renderContent?: RenderContentCustomization; }; + events?: LogsExplorerCustomizationEvents; } diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/hooks/use_data_views.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/hooks/use_data_views.tsx index a71375ff2ea25..60af9d3591adf 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/hooks/use_data_views.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/hooks/use_data_views.tsx @@ -8,12 +8,15 @@ import { useCallback } from 'react'; import createContainer from 'constate'; import { useInterpret, useSelector } from '@xstate/react'; -import { DataViewListItem, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataViewDescriptor } from '../../common/data_views/models/data_view_descriptor'; import { SortOrder } from '../../common/latest'; import { createDataViewsStateMachine } from '../state_machines/data_views'; +import { LogsExplorerCustomizations } from '../controller'; interface DataViewsContextDeps { dataViewsService: DataViewsPublicPluginStart; + events: LogsExplorerCustomizations['events']; } export interface SearchDataViewsParams { @@ -21,12 +24,12 @@ export interface SearchDataViewsParams { sortOrder: SortOrder; } -export type DataViewSelectionHandler = (dataView: DataViewListItem) => void; export type SearchDataViews = (params: SearchDataViewsParams) => void; export type LoadDataViews = () => void; export type ReloadDataViews = () => void; +export type IsDataViewAvailable = (dataView: DataViewDescriptor) => boolean; -const useDataViews = ({ dataViewsService }: DataViewsContextDeps) => { +const useDataViews = ({ dataViewsService, events }: DataViewsContextDeps) => { const dataViewsStateService = useInterpret(() => createDataViewsStateMachine({ dataViews: dataViewsService, @@ -39,6 +42,13 @@ const useDataViews = ({ dataViewsService }: DataViewsContextDeps) => { const isLoading = useSelector(dataViewsStateService, (state) => state.matches('loading')); + const isDataViewAvailable: IsDataViewAvailable = useCallback( + (dataView) => + dataView.isLogsDataType() || + (dataView.isUnknownDataType() && Boolean(events?.onUknownDataViewSelection)), + [events?.onUknownDataViewSelection] + ); + const loadDataViews = useCallback( () => dataViewsStateService.send({ type: 'LOAD_DATA_VIEWS' }), [dataViewsStateService] @@ -81,6 +91,7 @@ const useDataViews = ({ dataViewsService }: DataViewsContextDeps) => { dataViews, // Actions + isDataViewAvailable, loadDataViews, reloadDataViews, searchDataViews, diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/index.ts b/x-pack/plugins/observability_solution/logs_explorer/public/index.ts index 6e452f8904828..c768b3a7cb3f3 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/index.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/index.ts @@ -17,6 +17,7 @@ export type { } from './controller'; export type { LogsExplorerCustomizations, + LogsExplorerCustomizationEvents, LogsExplorerFlyoutContentProps, } from './customizations/types'; export type { LogsExplorerControllerContext } from './state_machines/logs_explorer_controller'; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/services/discover_service.ts b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/services/discover_service.ts index d67c74097d701..96fbe4ff34440 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/services/discover_service.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/services/discover_service.ts @@ -5,11 +5,10 @@ * 2.0. */ -import { DiscoverStart } from '@kbn/discover-plugin/public'; import { isEmpty } from 'lodash'; import { ActionFunction, actions, InvokeCallback } from 'xstate'; -import { getDiscoverColumnsWithFallbackFieldsFromDisplayOptions } from '../../../../utils/convert_discover_app_state'; -import { DataViewSelection, isDataViewSelection } from '../../../../../common/dataset_selection'; +import { LogsExplorerCustomizations } from '../../../../controller'; +import { isDataViewSelection } from '../../../../../common/dataset_selection'; import { getChartDisplayOptionsFromDiscoverAppState, getDiscoverAppStateFromContext, @@ -110,32 +109,14 @@ export const updateDiscoverAppStateFromContext: ActionFunction< context.discoverStateContainer.appState.update(getDiscoverAppStateFromContext(context)); }; -export const redirectToDiscoverAction = +export const redirectToDiscover = ( - discover: DiscoverStart + events?: LogsExplorerCustomizations['events'] ): ActionFunction => (context, event) => { if (event.type === 'UPDATE_DATASET_SELECTION' && isDataViewSelection(event.data)) { - return redirectToDiscover({ context, datasetSelection: event.data, discover }); + if (events?.onUknownDataViewSelection) { + return events.onUknownDataViewSelection({ ...context, datasetSelection: event.data }); + } } }; - -export const redirectToDiscover = ({ - context, - datasetSelection, - discover, -}: { - discover: DiscoverStart; - context: LogsExplorerControllerContext; - datasetSelection: DataViewSelection; -}) => { - return discover.locator?.navigate({ - breakdownField: context.chart.breakdownField ?? undefined, - columns: getDiscoverColumnsWithFallbackFieldsFromDisplayOptions(context), - dataViewSpec: datasetSelection.selection.dataView.toDataviewSpec(), - filters: context.filters, - query: context.query, - refreshInterval: context.refreshInterval, - timeRange: context.time, - }); -}; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/services/selection_service.ts b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/services/selection_service.ts index 281b446869276..bcf6bfce7730b 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/services/selection_service.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/services/selection_service.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { DiscoverStart } from '@kbn/discover-plugin/public'; import { InvokeCreator } from 'xstate'; +import { LogsExplorerCustomizations } from '../../../../controller'; import { Dataset } from '../../../../../common/datasets'; import { isDataViewSelection, @@ -16,17 +16,16 @@ import { } from '../../../../../common/dataset_selection'; import { IDatasetsClient } from '../../../../services/datasets'; import { LogsExplorerControllerContext, LogsExplorerControllerEvent } from '../types'; -import { redirectToDiscover } from './discover_service'; interface LogsExplorerControllerSelectionServiceDeps { datasetsClient: IDatasetsClient; - discover: DiscoverStart; + events?: LogsExplorerCustomizations['events']; } export const initializeSelection = ({ datasetsClient, - discover, + events, }: LogsExplorerControllerSelectionServiceDeps): InvokeCreator< LogsExplorerControllerContext, LogsExplorerControllerEvent @@ -39,9 +38,10 @@ export const initializeSelection = */ if ( isDataViewSelection(context.datasetSelection) && - context.datasetSelection.selection.dataView.isUnknownDataType() + context.datasetSelection.selection.dataView.isUnknownDataType() && + events?.onUknownDataViewSelection ) { - return redirectToDiscover({ context, datasetSelection: context.datasetSelection, discover }); + return events?.onUknownDataViewSelection(context); } /** diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/state_machine.ts b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/state_machine.ts index 3354fbbcd7a02..da393ef99213c 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/state_machine.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/public/state_machines/logs_explorer_controller/src/state_machine.ts @@ -8,7 +8,8 @@ import { IToasts } from '@kbn/core/public'; import { QueryStart } from '@kbn/data-plugin/public'; import { actions, createMachine, interpret, InterpreterFrom, raise } from 'xstate'; -import { LogsExplorerStartDeps } from '../../../types'; +import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { LogsExplorerCustomizations } from '../../../controller'; import { ControlPanelRT } from '../../../../common/control_panels'; import { AllDatasetSelection, @@ -28,7 +29,7 @@ import { } from './services/control_panels'; import { changeDataView, createAdHocDataView } from './services/data_view_service'; import { - redirectToDiscoverAction, + redirectToDiscover, subscribeToDiscoverState, updateContextFromDiscoverAppState, updateContextFromDiscoverDataState, @@ -319,7 +320,8 @@ export const createPureLogsExplorerControllerStateMachine = ( export interface LogsExplorerControllerStateMachineDependencies { datasetsClient: IDatasetsClient; - plugins: Pick; + dataViews: DataViewsPublicPluginStart; + events?: LogsExplorerCustomizations['events']; initialContext?: LogsExplorerControllerContext; query: QueryStart; toasts: IToasts; @@ -327,7 +329,8 @@ export interface LogsExplorerControllerStateMachineDependencies { export const createLogsExplorerControllerStateMachine = ({ datasetsClient, - plugins: { dataViews, discover }, + dataViews, + events, initialContext = DEFAULT_CONTEXT, query, toasts, @@ -336,14 +339,14 @@ export const createLogsExplorerControllerStateMachine = ({ actions: { notifyCreateDataViewFailed: createCreateDataViewFailedNotifier(toasts), notifyDatasetSelectionRestoreFailed: createDatasetSelectionRestoreFailedNotifier(toasts), - redirectToDiscover: redirectToDiscoverAction(discover), + redirectToDiscover: redirectToDiscover(events), updateTimefilterFromContext: updateTimefilterFromContext(query), }, services: { changeDataView: changeDataView({ dataViews }), createAdHocDataView: createAdHocDataView(), initializeControlPanels: initializeControlPanels(), - initializeSelection: initializeSelection({ datasetsClient, discover }), + initializeSelection: initializeSelection({ datasetsClient, events }), subscribeControlGroup: subscribeControlGroup(), updateControlPanels: updateControlPanels(), discoverStateService: subscribeToDiscoverState(), diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/logs_explorer_customizations/discover_navigation_handler.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/public/logs_explorer_customizations/discover_navigation_handler.ts new file mode 100644 index 0000000000000..8ffd223201c99 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/logs_explorer_customizations/discover_navigation_handler.ts @@ -0,0 +1,30 @@ +/* + * 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 { DiscoverStart } from '@kbn/discover-plugin/public'; +import { isDataViewSelection } from '@kbn/logs-explorer-plugin/common'; +import { + getDiscoverColumnsWithFallbackFieldsFromDisplayOptions, + LogsExplorerCustomizationEvents, +} from '@kbn/logs-explorer-plugin/public'; + +export const createOnUknownDataViewSelectionHandler = ( + discover: DiscoverStart +): LogsExplorerCustomizationEvents['onUknownDataViewSelection'] => { + return (context) => { + if (isDataViewSelection(context.datasetSelection)) + discover.locator?.navigate({ + breakdownField: context.chart.breakdownField ?? undefined, + columns: getDiscoverColumnsWithFallbackFieldsFromDisplayOptions(context), + dataViewSpec: context.datasetSelection.selection.dataView.toDataviewSpec(), + filters: context.filters, + query: context.query, + refreshInterval: context.refreshInterval, + timeRange: context.time, + }); + }; +}; diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/logs_explorer_customizations/index.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/public/logs_explorer_customizations/index.ts index 1a10437340344..9e8c3061d6f55 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/logs_explorer_customizations/index.ts +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/logs_explorer_customizations/index.ts @@ -6,15 +6,23 @@ */ import { CreateLogsExplorerController } from '@kbn/logs-explorer-plugin/public'; +import { PluginKibanaContextValue } from '../utils/use_kibana'; +import { createOnUknownDataViewSelectionHandler } from './discover_navigation_handler'; import { renderFlyoutContent } from './flyout_content'; export const createLogsExplorerControllerWithCustomizations = - (createLogsExplorerController: CreateLogsExplorerController): CreateLogsExplorerController => + ( + createLogsExplorerController: CreateLogsExplorerController, + services: PluginKibanaContextValue + ): CreateLogsExplorerController => (args) => createLogsExplorerController({ ...args, customizations: { ...args.customizations, + events: { + onUknownDataViewSelection: createOnUknownDataViewSelectionHandler(services.discover), + }, flyout: { renderContent: renderFlyoutContent, }, diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/routes/main/main_route.tsx b/x-pack/plugins/observability_solution/observability_logs_explorer/public/routes/main/main_route.tsx index b9468354955e2..be071eb1dfcc3 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/routes/main/main_route.tsx +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/routes/main/main_route.tsx @@ -35,8 +35,12 @@ export const ObservabilityLogsExplorerMainRoute = () => { const urlStateStorageContainer = useKbnUrlStateStorageFromRouterContext(); const createLogsExplorerController = useMemo( - () => createLogsExplorerControllerWithCustomizations(logsExplorer.createLogsExplorerController), - [logsExplorer.createLogsExplorerController] + () => + createLogsExplorerControllerWithCustomizations( + logsExplorer.createLogsExplorerController, + services + ), + [logsExplorer.createLogsExplorerController, services] ); return ( From 591d2512e2ef4ba0c7756af6426e6ad17260a2be Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Thu, 15 Feb 2024 09:10:44 -0700 Subject: [PATCH 09/23] [ML] Data Frame Analytics Creation functional tests: ensure test consistency (#176939) ## Summary Fixes https://github.com/elastic/kibana/issues/147020 As discussed, in this PR, the chart color assertions are temporarily disabled as the colors can vary quite a bit on each run and cause flakiness. Created an [issue](https://github.com/elastic/kibana/issues/176938) for creating a better solution. ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: --- .../classification_creation_saved_search.ts | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts index 307678384d470..eb81e2d8de15a 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts @@ -14,8 +14,7 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); const editedDescription = 'Edited description'; - // FLAKY: https://github.com/elastic/kibana/issues/147020 - describe.skip('classification saved search creation', function () { + describe('classification saved search creation', function () { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote_small'); await ml.testResources.createDataViewIfNeeded('ft_farequote_small', '@timestamp'); @@ -684,16 +683,20 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); await ml.testExecution.logTestStep('displays the ROC curve chart'); - await ml.commonUI.assertColorsInCanvasElement( - 'mlDFAnalyticsClassificationExplorationRocCurveChart', - testData.expected.rocCurveColorState, - ['#000000'], - undefined, - undefined, - // increased tolerance for ROC curve chart up from 10 to 20 - // since the returned colors vary quite a bit on each run. - 20 - ); + + // NOTE: Temporarily disabling these assertions since the colors can vary quite a bit on each run and cause flakiness + // Tracking in https://github.com/elastic/kibana/issues/176938 + + // await ml.commonUI.assertColorsInCanvasElement( + // 'mlDFAnalyticsClassificationExplorationRocCurveChart', + // testData.expected.rocCurveColorState, + // ['#000000'], + // undefined, + // undefined, + // // increased tolerance for ROC curve chart up from 10 to 20 + // // since the returned colors vary quite a bit on each run. + // 20 + // ); await ml.commonUI.resetAntiAliasing(); }); From 0d787a01f3fb9f16fca811be8dbc1d7a08241b96 Mon Sep 17 00:00:00 2001 From: Ash <1849116+ashokaditya@users.noreply.github.com> Date: Thu, 15 Feb 2024 17:48:06 +0100 Subject: [PATCH 10/23] [8.13][Security Solution][Endpoint] Add missing tests for bidirectional connector response actions (#176824) ## Summary Tests for responder action item on alert action menu. for changes in elastic/kibana/pull/176405 ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../use_responder_action_data.test.ts | 140 ++++++++++++++++++ .../edit_connector_flyout/index.test.tsx | 34 ++++- 2 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.test.ts diff --git a/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.test.ts b/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.test.ts new file mode 100644 index 0000000000000..5613cf551b464 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useResponderActionData } from './use_responder_action_data'; +import { renderHook } from '@testing-library/react-hooks'; +import { useGetEndpointDetails } from '../../../management/hooks'; +import { HostStatus } from '../../../../common/endpoint/types'; + +jest.mock('../../../common/hooks/use_experimental_features'); +jest.mock('../../../management/hooks', () => ({ + useGetEndpointDetails: (jest.fn() as jest.Mock).mockImplementation(() => ({ enabled: false })), + useWithShowResponder: jest.fn(), +})); + +const useGetEndpointDetailsMock = useGetEndpointDetails as jest.Mock; +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; + +describe('#useResponderActionData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return `responder` menu item as `disabled` if agentType is not `endpoint` and feature flag is enabled', () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(false); + + const { result } = renderHook(() => + useResponderActionData({ + endpointId: 'some-agent-type-id', + // @ts-expect-error this is for testing purpose + agentType: 'some_agent_type', + eventData: [], + }) + ); + expect(result.current.isDisabled).toEqual(true); + }); + + describe('when agentType is `endpoint`', () => { + it.each(Object.values(HostStatus).filter((status) => status !== 'unenrolled'))( + 'should return `responder` menu item as `enabled `if agentType is `endpoint` when endpoint is %s', + (hostStatus) => { + useGetEndpointDetailsMock.mockReturnValue({ + data: { + host_status: hostStatus, + }, + isFetching: false, + error: undefined, + }); + const { result } = renderHook(() => + useResponderActionData({ + endpointId: 'endpoint-id', + agentType: 'endpoint', + }) + ); + expect(result.current.isDisabled).toEqual(false); + } + ); + + it('should return responder menu item `disabled` if agentType is `endpoint` when endpoint is `unenrolled`', () => { + useGetEndpointDetailsMock.mockReturnValue({ + data: { + host_status: 'unenrolled', + }, + isFetching: false, + error: undefined, + }); + const { result } = renderHook(() => + useResponderActionData({ + endpointId: 'endpoint-id', + agentType: 'endpoint', + }) + ); + expect(result.current.isDisabled).toEqual(true); + }); + + it('should return responder menu item `disabled` if agentType is `endpoint` when endpoint data has error', () => { + useGetEndpointDetailsMock.mockReturnValue({ + data: { + host_status: 'online', + }, + isFetching: false, + error: new Error('uh oh!'), + }); + const { result } = renderHook(() => + useResponderActionData({ + endpointId: 'endpoint-id', + agentType: 'endpoint', + }) + ); + expect(result.current.isDisabled).toEqual(true); + }); + + it('should return responder menu item `disabled` if agentType is `endpoint` and endpoint data is fetching', () => { + useGetEndpointDetailsMock.mockReturnValue({ + data: undefined, + isFetching: true, + error: undefined, + }); + + const { result } = renderHook(() => + useResponderActionData({ + endpointId: 'endpoint-id', + agentType: 'endpoint', + }) + ); + expect(result.current.isDisabled).toEqual(true); + }); + }); + + describe('when agentType is `sentinel_one`', () => { + it('should return `responder` menu item as `disabled` if agentType is `sentinel_one` and feature flag is disabled', () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(false); + + const { result } = renderHook(() => + useResponderActionData({ + endpointId: 'sentinel-one-id', + agentType: 'sentinel_one', + eventData: [], + }) + ); + expect(result.current.isDisabled).toEqual(true); + }); + + it('should return `responder` menu item as `enabled `if agentType is `sentinel_one` and feature flag is enabled', () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + const { result } = renderHook(() => + useResponderActionData({ + endpointId: 'sentinel-one-id', + agentType: 'sentinel_one', + eventData: [], + }) + ); + expect(result.current.isDisabled).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx index 001cab3fc0720..3bdeb84e4bc19 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx @@ -12,7 +12,7 @@ import userEvent from '@testing-library/user-event'; import { act, waitFor } from '@testing-library/react'; import EditConnectorFlyout from '.'; import { ActionConnector, EditConnectorTabs, GenericValidationResult } from '../../../../types'; -import { technicalPreviewBadgeProps } from '../beta_badge_props'; +import { betaBadgeProps, technicalPreviewBadgeProps } from '../beta_badge_props'; import { AppMockRenderer, createAppMockRenderer } from '../../test_utils'; const updateConnectorResponse = { @@ -311,7 +311,7 @@ describe('EditConnectorFlyout', () => { expect(getByTestId('preconfiguredBadge')).toBeInTheDocument(); }); - it('does not show tech preview badge when isExperimental is false', async () => { + it('does not show `tech preview` badge when isExperimental is false', async () => { const { queryByText } = appMockRenderer.render( { expect(queryByText(technicalPreviewBadgeProps.label)).not.toBeInTheDocument(); }); - it('shows tech preview badge when isExperimental is true', async () => { + it('shows `tech preview` badge when isExperimental is true', async () => { actionTypeRegistry.get.mockReturnValue({ ...actionTypeModel, isExperimental: true }); const { getByText } = appMockRenderer.render( { await act(() => Promise.resolve()); expect(getByText(technicalPreviewBadgeProps.label)).toBeInTheDocument(); }); + + it('does not show `beta` badge when `isBeta` is `false`', async () => { + actionTypeRegistry.get.mockReturnValue({ ...actionTypeModel, isBeta: false }); + const { queryByText } = appMockRenderer.render( + + ); + await act(() => Promise.resolve()); + expect(queryByText(betaBadgeProps.label)).not.toBeInTheDocument(); + }); + + it('shows `beta` badge when `isBeta` is `true`', async () => { + actionTypeRegistry.get.mockReturnValue({ ...actionTypeModel, isBeta: true }); + const { getByText } = appMockRenderer.render( + + ); + await act(() => Promise.resolve()); + expect(getByText(betaBadgeProps.label)).toBeInTheDocument(); + }); }); describe('Tabs', () => { From 68d6ab21354bcf0504dc3664b818ab07f94340bc Mon Sep 17 00:00:00 2001 From: Alexi Doak <109488926+doakalexi@users.noreply.github.com> Date: Thu, 15 Feb 2024 09:13:06 -0800 Subject: [PATCH 11/23] [ResponseOps][FE] Alert creation delay based on user definition (#176346) Resolves https://github.com/elastic/kibana/issues/173009 ## Summary Adds a new input for the user to define the `alertDelay`. This input is available for life-cycled alerts (stack and o11y) rule types. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### To verify - Using the UI create a rule with the `alertDelay` field set. - Verify that the field is saved properly and that you can edit the `alertDelay` - Verify that you can add the alert delay to existing rules. Create a rule in a different branch and switch to this one. Edit the rule and set the `alertDelay`. Verify that the rule saves and works as expected. --------- Co-authored-by: Lisa Cawley --- .../server/routes/lib/rewrite_rule.test.ts | 3 + .../server/routes/lib/rewrite_rule.ts | 2 + .../server/routes/update_rule.test.ts | 10 ++ .../alerting/server/routes/update_rule.ts | 10 +- .../server/rules_client/methods/update.ts | 3 +- .../server/rules_client/tests/update.test.ts | 12 ++ .../lib/rule_api/common_transformations.ts | 2 + .../application/lib/rule_api/create.test.ts | 9 ++ .../public/application/lib/rule_api/create.ts | 2 + .../application/lib/rule_api/update.test.ts | 5 +- .../public/application/lib/rule_api/update.ts | 15 +- .../sections/rule_form/rule_form.test.tsx | 21 +++ .../sections/rule_form/rule_form.tsx | 143 ++++++++++++------ .../sections/rule_form/rule_reducer.test.ts | 17 +++ .../sections/rule_form/rule_reducer.ts | 29 ++++ 15 files changed, 232 insertions(+), 51 deletions(-) diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts index 7a348e583ac6c..826ee952a6bb6 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts @@ -62,6 +62,9 @@ const sampleRule: SanitizedRule & { activeSnoozes?: string[] } = }, nextRun: DATE_2020, revision: 0, + alertDelay: { + active: 10, + }, }; describe('rewriteRule', () => { diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts index 953211a5ef4f7..d0e59278b13c5 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts @@ -37,6 +37,7 @@ export const rewriteRule = ({ activeSnoozes, lastRun, nextRun, + alertDelay, ...rest }: SanitizedRule & { activeSnoozes?: string[] }) => ({ ...rest, @@ -78,4 +79,5 @@ export const rewriteRule = ({ ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), ...(nextRun ? { next_run: nextRun } : {}), ...(apiKeyCreatedByUser !== undefined ? { api_key_created_by_user: apiKeyCreatedByUser } : {}), + ...(alertDelay !== undefined ? { alert_delay: alertDelay } : {}), }); diff --git a/x-pack/plugins/alerting/server/routes/update_rule.test.ts b/x-pack/plugins/alerting/server/routes/update_rule.test.ts index 5a4b3a19c0d7c..b48e6d72bef3f 100644 --- a/x-pack/plugins/alerting/server/routes/update_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/update_rule.test.ts @@ -59,6 +59,9 @@ describe('updateRuleRoute', () => { }, ], notifyWhen: RuleNotifyWhen.CHANGE, + alertDelay: { + active: 10, + }, }; const updateRequest: AsApiContract['data']> = { @@ -73,6 +76,9 @@ describe('updateRuleRoute', () => { alerts_filter: mockedAlert.actions[0].alertsFilter, }, ], + alert_delay: { + active: 10, + }, }; const updateResult: AsApiContract> = { @@ -86,6 +92,7 @@ describe('updateRuleRoute', () => { connector_type_id: actionTypeId, alerts_filter: alertsFilter, })), + alert_delay: mockedAlert.alertDelay, }; it('updates a rule with proper parameters', async () => { @@ -135,6 +142,9 @@ describe('updateRuleRoute', () => { "uuid": "1234-5678", }, ], + "alertDelay": Object { + "active": 10, + }, "name": "abc", "notifyWhen": "onActionGroupChange", "params": Object { diff --git a/x-pack/plugins/alerting/server/routes/update_rule.ts b/x-pack/plugins/alerting/server/routes/update_rule.ts index d24af256de613..9419d84d06341 100644 --- a/x-pack/plugins/alerting/server/routes/update_rule.ts +++ b/x-pack/plugins/alerting/server/routes/update_rule.ts @@ -52,16 +52,22 @@ const bodySchema = schema.object({ ) ) ), + alert_delay: schema.maybe( + schema.object({ + active: schema.number(), + }) + ), }); const rewriteBodyReq: RewriteRequestCase> = (result) => { - const { notify_when: notifyWhen, actions, ...rest } = result.data; + const { notify_when: notifyWhen, alert_delay: alertDelay, actions, ...rest } = result.data; return { ...result, data: { ...rest, notifyWhen, actions: rewriteActionsReq(actions), + alertDelay, }, }; }; @@ -83,6 +89,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ isSnoozedUntil, lastRun, nextRun, + alertDelay, ...rest }) => ({ ...rest, @@ -115,6 +122,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), ...(nextRun ? { next_run: nextRun } : {}), ...(apiKeyCreatedByUser !== undefined ? { api_key_created_by_user: apiKeyCreatedByUser } : {}), + ...(alertDelay ? { alert_delay: alertDelay } : {}), }); export const updateRuleRoute = ( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/update.ts b/x-pack/plugins/alerting/server/rules_client/methods/update.ts index 33afee4c20d26..1255173beefe4 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/update.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/update.ts @@ -17,7 +17,7 @@ import { } from '../../types'; import { validateRuleTypeParams, getRuleNotifyWhenType } from '../../lib'; import { WriteOperations, AlertingAuthorizationEntity } from '../../authorization'; -import { parseDuration, getRuleCircuitBreakerErrorMessage } from '../../../common'; +import { parseDuration, getRuleCircuitBreakerErrorMessage, AlertDelay } from '../../../common'; import { retryIfConflicts } from '../../lib/retry_if_conflicts'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events'; @@ -51,6 +51,7 @@ export interface UpdateOptions { params: Params; throttle?: string | null; notifyWhen?: RuleNotifyWhenType | null; + alertDelay?: AlertDelay; }; allowMissingConnectorSecrets?: boolean; shouldIncrementRevision?: ShouldIncrementRevision; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index be3221c8ed2f1..7384ab467a8e9 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -279,6 +279,9 @@ describe('update()', () => { scheduledTaskId: 'task-123', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + alertDelay: { + active: 5, + }, }, references: [ { @@ -334,6 +337,9 @@ describe('update()', () => { }, }, ], + alertDelay: { + active: 10, + }, }, }); expect(result).toMatchInlineSnapshot(` @@ -364,6 +370,9 @@ describe('update()', () => { }, }, ], + "alertDelay": Object { + "active": 5, + }, "createdAt": 2019-02-12T21:01:22.479Z, "enabled": true, "id": "1", @@ -422,6 +431,9 @@ describe('update()', () => { "uuid": "102", }, ], + "alertDelay": Object { + "active": 10, + }, "alertTypeId": "myType", "apiKey": null, "apiKeyCreatedByUser": null, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts index 64949d9014c50..b00b874b079ef 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts @@ -77,6 +77,7 @@ export const transformRule: RewriteRequestCase = ({ active_snoozes: activeSnoozes, last_run: lastRun, next_run: nextRun, + alert_delay: alertDelay, ...rest }: any) => ({ ruleTypeId, @@ -99,6 +100,7 @@ export const transformRule: RewriteRequestCase = ({ ...(lastRun ? { lastRun: transformLastRun(lastRun) } : {}), ...(nextRun ? { nextRun } : {}), ...(apiKeyCreatedByUser !== undefined ? { apiKeyCreatedByUser } : {}), + ...(alertDelay ? { alertDelay } : {}), ...rest, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.test.ts index 76e5fd7f09207..b27d9cad0c056 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.test.ts @@ -52,6 +52,9 @@ describe('createRule', () => { execution_status: { status: 'pending', last_execution_date: '2021-04-01T21:33:13.250Z' }, create_at: '2021-04-01T21:33:13.247Z', updated_at: '2021-04-01T21:33:13.247Z', + alert_delay: { + active: 10, + }, }; const ruleToCreate: Omit< RuleUpdates, @@ -96,6 +99,9 @@ describe('createRule', () => { updatedAt: new Date('2021-04-01T21:33:13.247Z'), apiKeyOwner: '', revision: 0, + alertDelay: { + active: 10, + }, }; http.post.mockResolvedValueOnce(resolvedValue); @@ -148,6 +154,9 @@ describe('createRule', () => { tags: [], updatedAt: '2021-04-01T21:33:13.247Z', updatedBy: undefined, + alertDelay: { + active: 10, + }, }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.ts index 2304ee8c48930..48fa1783f3c1f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.ts @@ -23,6 +23,7 @@ type RuleCreateBody = Omit< const rewriteBodyRequest: RewriteResponseCase = ({ ruleTypeId, actions, + alertDelay, ...res }): any => ({ ...res, @@ -43,6 +44,7 @@ const rewriteBodyRequest: RewriteResponseCase = ({ : {}), }) ), + ...(alertDelay ? { alert_delay: alertDelay } : {}), }); export async function createRule({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.test.ts index 8b3ebc3f96e52..591cdc83e86cf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.test.ts @@ -27,6 +27,9 @@ describe('updateRule', () => { apiKey: null, apiKeyOwner: null, revision: 0, + alertDelay: { + active: 10, + }, }; const resolvedValue: Rule = { ...ruleToUpdate, @@ -51,7 +54,7 @@ describe('updateRule', () => { Array [ "/api/alerting/rule/12%2F3", Object { - "body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[]}", + "body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"alert_delay\\":{\\"active\\":10}}", }, ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.ts index 52158bfa2f034..80346ff2f65da 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.ts @@ -13,9 +13,13 @@ import { transformRule } from './common_transformations'; type RuleUpdatesBody = Pick< RuleUpdates, - 'name' | 'tags' | 'schedule' | 'actions' | 'params' | 'throttle' | 'notifyWhen' + 'name' | 'tags' | 'schedule' | 'actions' | 'params' | 'throttle' | 'notifyWhen' | 'alertDelay' >; -const rewriteBodyRequest: RewriteResponseCase = ({ actions, ...res }): any => ({ +const rewriteBodyRequest: RewriteResponseCase = ({ + actions, + alertDelay, + ...res +}): any => ({ ...res, actions: actions.map( ({ group, id, params, frequency, uuid, alertsFilter, useAlertDataForTemplate }) => ({ @@ -34,6 +38,7 @@ const rewriteBodyRequest: RewriteResponseCase = ({ actions, ... ...(uuid && { uuid }), }) ), + ...(alertDelay ? { alert_delay: alertDelay } : {}), }); export async function updateRule({ @@ -42,14 +47,16 @@ export async function updateRule({ id, }: { http: HttpSetup; - rule: Pick; + rule: Pick; id: string; }): Promise { const res = await http.put>( `${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}`, { body: JSON.stringify( - rewriteBodyRequest(pick(rule, ['name', 'tags', 'schedule', 'params', 'actions'])) + rewriteBodyRequest( + pick(rule, ['name', 'tags', 'schedule', 'params', 'actions', 'alertDelay']) + ) ), } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx index 24592566d5465..8902b4d472ad2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx @@ -375,6 +375,9 @@ describe('rule_form', () => { enabled: false, mutedInstanceIds: [], ...(!showRulesList ? { ruleTypeId: ruleType.id } : {}), + alertDelay: { + active: 1, + }, } as unknown as Rule; wrapper = mountWithIntl( @@ -1034,6 +1037,24 @@ describe('rule_form', () => { expect(wrapper.find(ActionForm).props().hasFieldsForAAD).toEqual(true); }); + + it('renders rule alert delay', async () => { + const getAlertDelayInput = () => { + return wrapper.find('[data-test-subj="alertDelayInput"] input').first(); + }; + + await setup(); + expect(getAlertDelayInput().props().value).toEqual(1); + + getAlertDelayInput().simulate('change', { target: { value: '2' } }); + expect(getAlertDelayInput().props().value).toEqual(2); + + getAlertDelayInput().simulate('change', { target: { value: '20' } }); + expect(getAlertDelayInput().props().value).toEqual(20); + + getAlertDelayInput().simulate('change', { target: { value: '999' } }); + expect(getAlertDelayInput().props().value).toEqual(999); + }); }); describe('rule_form create rule non ruleing consumer and producer', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index 98e2547dabdd3..b0b052544b625 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -215,6 +215,7 @@ export const RuleForm = ({ ? getDurationUnitValue(rule.schedule.interval) : defaultScheduleIntervalUnit ); + const [alertDelay, setAlertDelay] = useState(rule.alertDelay?.active ?? 1); const [defaultActionGroupId, setDefaultActionGroupId] = useState(undefined); const [availableRuleTypes, setAvailableRuleTypes] = useState([]); @@ -328,6 +329,12 @@ export const RuleForm = ({ } }, [rule.schedule.interval, defaultScheduleInterval, defaultScheduleIntervalUnit]); + useEffect(() => { + if (rule.alertDelay) { + setAlertDelay(rule.alertDelay.active); + } + }, [rule.alertDelay]); + useEffect(() => { if (!flyoutBodyOverflowRef.current) { // We're using this as a reliable way to reset the scroll position @@ -393,6 +400,10 @@ export const RuleForm = ({ [dispatch] ); + const setAlertDelayProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setAlertDelayProperty' }, payload: { key, value } }); + }; + useEffect(() => { const searchValue = searchText ? searchText.trim().toLocaleLowerCase() : null; setFilteredRuleTypes( @@ -766,51 +777,95 @@ export const RuleForm = ({ ) : null} {hideInterval !== true && ( - - 0} - error={errors['schedule.interval']} - > - - - 0} - value={ruleInterval || ''} - name="interval" - data-test-subj="intervalInput" - onChange={(e) => { - const value = e.target.value; - if (value === '' || INTEGER_REGEX.test(value)) { - const parsedValue = value === '' ? '' : parseInt(value, 10); - setRuleInterval(parsedValue || undefined); - setScheduleProperty('interval', `${parsedValue}${ruleIntervalUnit}`); - } - }} - /> - - - { - setRuleIntervalUnit(e.target.value); - setScheduleProperty('interval', `${ruleInterval}${e.target.value}`); - }} - data-test-subj="intervalInputUnit" - /> - - - - + <> + + 0} + error={errors['schedule.interval']} + > + + + 0} + value={ruleInterval || ''} + name="interval" + data-test-subj="intervalInput" + onChange={(e) => { + const value = e.target.value; + if (value === '' || INTEGER_REGEX.test(value)) { + const parsedValue = value === '' ? '' : parseInt(value, 10); + setRuleInterval(parsedValue || undefined); + setScheduleProperty('interval', `${parsedValue}${ruleIntervalUnit}`); + } + }} + /> + + + { + setRuleIntervalUnit(e.target.value); + setScheduleProperty('interval', `${ruleInterval}${e.target.value}`); + }} + data-test-subj="intervalInputUnit" + /> + + + + + + )} + + + + + } + />, + ]} + append={i18n.translate( + 'xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldAppendLabel', + { + defaultMessage: 'consecutive matches', + } + )} + onChange={(e) => { + const value = e.target.value; + if (value === '' || INTEGER_REGEX.test(value)) { + const parsedValue = value === '' ? '' : parseInt(value, 10); + setAlertDelayProperty('active', parsedValue || 1); + setAlertDelay(parsedValue || undefined); + } + }} + /> + + {shouldShowConsumerSelect && ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.test.ts index 996676b73d59e..6eadf1fce5ff4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.test.ts @@ -21,6 +21,9 @@ describe('rule reducer', () => { actions: [], tags: [], notifyWhen: 'onActionGroupChange', + alertDelay: { + active: 5, + }, } as unknown as Rule; }); @@ -211,4 +214,18 @@ describe('rule reducer', () => { ); expect(updatedRule.rule.actions[0].frequency?.notifyWhen).toBe('onThrottleInterval'); }); + + test('if initial alert delay property was updated', () => { + const updatedRule = ruleReducer( + { rule: initialRule }, + { + command: { type: 'setAlertDelayProperty' }, + payload: { + key: 'active', + value: 10, + }, + } + ); + expect(updatedRule.rule.alertDelay?.active).toBe(10); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts index 54f3871928fb3..257df764ebc1e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts @@ -12,6 +12,7 @@ import { RuleActionParam, IntervalSchedule, RuleActionAlertsFilterProperty, + AlertDelay, } from '@kbn/alerting-plugin/common'; import { isEmpty } from 'lodash/fp'; import { Rule, RuleAction } from '../../../types'; @@ -30,6 +31,7 @@ interface CommandType< | 'setRuleActionProperty' | 'setRuleActionFrequency' | 'setRuleActionAlertsFilter' + | 'setAlertDelayProperty' > { type: T; } @@ -62,6 +64,12 @@ interface RuleSchedulePayload { index?: number; } +interface AlertDelayPayload { + key: Key; + value: AlertDelay[Key] | null; + index?: number; +} + export type RuleReducerAction = | { command: CommandType<'setRule'>; @@ -94,6 +102,10 @@ export type RuleReducerAction = | { command: CommandType<'setRuleActionAlertsFilter'>; payload: Payload; + } + | { + command: CommandType<'setAlertDelayProperty'>; + payload: AlertDelayPayload; }; export type InitialRuleReducer = Reducer<{ rule: InitialRule }, RuleReducerAction>; @@ -281,5 +293,22 @@ export const ruleReducer = ( }; } } + case 'setAlertDelayProperty': { + const { key, value } = action.payload as Payload; + if (rule.alertDelay && isEqual(rule.alertDelay[key], value)) { + return state; + } else { + return { + ...state, + rule: { + ...rule, + alertDelay: { + ...rule.alertDelay, + [key]: value, + }, + }, + }; + } + } } }; From 9488c93b3d7adc7a6617ff98b3c61769e147b0b4 Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Thu, 15 Feb 2024 18:25:20 +0100 Subject: [PATCH 12/23] Notify the response ops when there is change on connector config (#175981) Resolves: #175018 This Pr adds an integration test to check the changes on connectorTypes config, secrets and params schemas. I used `validate.schema` field as all the connector types have it. ConnectorTypes has config, secrets and params schemas on `validate.schema` whereas SubActionConnectorTypes has only config and secrets. They have multiple params schema as well but only registered and used during action execution. e.g. https://github.com/ersin-erdal/kibana/blob/main/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts#L57 And here is the explanation why they are not listed in a definition: https://github.com/ersin-erdal/kibana/blob/main/x-pack/plugins/actions/server/sub_action_framework/validators.ts#L38 We need to do some refactoring to list those schemas on the connector types. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../actions/jest.integration.config.js | 2 +- .../connector_types.test.ts.snap | 11170 ++++++++++++++++ .../integration_tests/connector_types.test.ts | 60 + .../server/integration_tests/lib/index.ts | 8 + .../lib/setup_test_servers.ts | 37 + .../mocks/connector_types.ts | 32 + x-pack/plugins/actions/server/types.ts | 8 +- x-pack/plugins/actions/tsconfig.json | 3 +- .../rules_client/methods/get_alert_state.ts | 29 +- .../tests/get_alert_state.test.ts | 65 + 10 files changed, 11400 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap create mode 100644 x-pack/plugins/actions/server/integration_tests/connector_types.test.ts create mode 100644 x-pack/plugins/actions/server/integration_tests/lib/index.ts create mode 100644 x-pack/plugins/actions/server/integration_tests/lib/setup_test_servers.ts create mode 100644 x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts diff --git a/x-pack/plugins/actions/jest.integration.config.js b/x-pack/plugins/actions/jest.integration.config.js index 41bd46b12005e..daea840925756 100644 --- a/x-pack/plugins/actions/jest.integration.config.js +++ b/x-pack/plugins/actions/jest.integration.config.js @@ -6,7 +6,7 @@ */ module.exports = { - preset: '@kbn/test/jest_integration_node', + preset: '@kbn/test/jest_integration', rootDir: '../../..', roots: ['/x-pack/plugins/actions'], }; diff --git a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap new file mode 100644 index 0000000000000..11624752650e4 --- /dev/null +++ b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -0,0 +1,11170 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Connector type config checks detect connector type changes for: .bedrock 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "defaultModel": Object { + "flags": Object { + "default": "anthropic.claude-v2:1", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .bedrock 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "accessKey": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "secret": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .bedrock 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .cases-webhook 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "createCommentJson": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "createCommentMethod": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": "put", + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "post", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "put", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "patch", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "createCommentUrl": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "createIncidentJson": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "createIncidentMethod": Object { + "flags": Object { + "default": "post", + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "post", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "put", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "createIncidentResponseKey": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "createIncidentUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "getIncidentResponseExternalTitleKey": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "getIncidentUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "hasAuth": Object { + "flags": Object { + "default": true, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + "headers": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "value": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "name": "entries", + }, + ], + "type": "record", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "updateIncidentJson": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "updateIncidentMethod": Object { + "flags": Object { + "default": "put", + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "post", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "patch", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "put", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "updateIncidentUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "viewIncidentUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .cases-webhook 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "password": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "user": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .cases-webhook 3`] = ` +Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "pushToService", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comments": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comment": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "commentId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "incident": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "description": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "externalId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "id": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "severity": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "status": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "tags": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "title": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + ], + "type": "alternatives", +} +`; + +exports[`Connector type config checks detect connector type changes for: .d3security 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "url": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .d3security 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "token": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .d3security 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .email 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "clientId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "from": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "hasAuth": Object { + "flags": Object { + "default": true, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + "host": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "oauthTokenUrl": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "port": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "limit": 1, + }, + "name": "min", + }, + Object { + "args": Object { + "limit": 65535, + }, + "name": "max", + }, + ], + "type": "number", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "secure": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "type": "boolean", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "service": Object { + "flags": Object { + "default": "other", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "tenantId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .email 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "clientSecret": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "password": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "user": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .email 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "bcc": Object { + "flags": Object { + "default": Array [], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + "cc": Object { + "flags": Object { + "default": Array [], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + "kibanaFooterLink": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "path": Object { + "flags": Object { + "default": "/", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "text": Object { + "flags": Object { + "default": "Go to Elastic", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + "message": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "messageHTML": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "subject": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "to": Object { + "flags": Object { + "default": Array [], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .gen-ai 1`] = ` +Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiProvider": Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "Azure OpenAI", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiProvider": Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "OpenAI", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "defaultModel": Object { + "flags": Object { + "default": "gpt-4", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + ], + "type": "alternatives", +} +`; + +exports[`Connector type config checks detect connector type changes for: .gen-ai 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiKey": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .gen-ai 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .index 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "executionTimeField": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "index": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "refresh": Object { + "flags": Object { + "default": false, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .index 2`] = ` +Object { + "flags": Object { + "default": Object {}, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .index 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "documents": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "value": Object { + "flags": Object { + "error": [Function], + }, + "type": "any", + }, + }, + "name": "entries", + }, + ], + "type": "record", + }, + ], + "type": "array", + }, + "indexOverride": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .jira 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "projectKey": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .jira 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiToken": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "email": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .jira 3`] = ` +Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getFields", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getIncident", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "externalId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "handshake", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "pushToService", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comments": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comment": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "commentId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "incident": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "description": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "externalId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "issueType": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "labels": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "parent": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "priority": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "summary": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "issueTypes", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "fieldsByIssueType", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "issues", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "title": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "issue", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + ], + "type": "alternatives", +} +`; + +exports[`Connector type config checks detect connector type changes for: .opsgenie 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .opsgenie 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiKey": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .opsgenie 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .pagerduty 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .pagerduty 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "routingKey": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .pagerduty 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "class": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "component": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "customDetails": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "value": Object { + "flags": Object { + "error": [Function], + }, + "type": "any", + }, + }, + "name": "entries", + }, + ], + "type": "record", + }, + "dedupKey": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "eventAction": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "trigger", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "resolve", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "acknowledge", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "group": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "links": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "href": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "text": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + "severity": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "critical", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "error", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "warning", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "info", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "source": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "summary": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "timestamp": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .resilient 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "orgId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .resilient 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiKeyId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "apiKeySecret": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .resilient 3`] = ` +Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getFields", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getIncident", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "externalId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "handshake", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "pushToService", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comments": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comment": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "commentId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "incident": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "description": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "externalId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "incidentTypes": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "type": "number", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "severityCode": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "type": "number", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "incidentTypes", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "severity", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + ], + "type": "alternatives", +} +`; + +exports[`Connector type config checks detect connector type changes for: .sentinelone 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "url": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .sentinelone 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "token": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .sentinelone 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .server-log 1`] = ` +Object { + "flags": Object { + "default": Object {}, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .server-log 2`] = ` +Object { + "flags": Object { + "default": Object {}, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .server-log 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "level": Object { + "flags": Object { + "default": "info", + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "trace", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "debug", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "info", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "warn", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "error", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "fatal", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "message": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .servicenow 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "clientId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "isOAuth": Object { + "flags": Object { + "default": false, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + "jwtKeyId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "userIdentifierValue": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "usesTableApi": Object { + "flags": Object { + "default": true, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .servicenow 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "clientSecret": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "password": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "privateKey": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "privateKeyPassword": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "username": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .servicenow 3`] = ` +Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getFields", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getIncident", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "externalId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "handshake", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "pushToService", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comments": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comment": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "commentId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "incident": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "category": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "correlation_display": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "correlation_id": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": "{{rule.id}}:{{alert.id}}", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "description": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "externalId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "impact": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "severity": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "short_description": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "subcategory": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "urgency": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getChoices", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fields": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "closeIncident", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "incident": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "correlation_id": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": "{{rule.id}}:{{alert.id}}", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "externalId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + ], + "type": "alternatives", +} +`; + +exports[`Connector type config checks detect connector type changes for: .servicenow-itom 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "clientId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "isOAuth": Object { + "flags": Object { + "default": false, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + "jwtKeyId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "userIdentifierValue": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .servicenow-itom 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "clientSecret": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "password": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "privateKey": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "privateKeyPassword": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "username": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .servicenow-itom 3`] = ` +Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "addEvent", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "additional_info": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "description": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "event_class": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "message_key": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": "{{rule.id}}:{{alert.id}}", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "metric_name": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "node": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "resource": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "severity": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "source": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "time_of_event": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "type": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getChoices", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fields": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + ], + "type": "alternatives", +} +`; + +exports[`Connector type config checks detect connector type changes for: .servicenow-sir 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "clientId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "isOAuth": Object { + "flags": Object { + "default": false, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + "jwtKeyId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "userIdentifierValue": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "usesTableApi": Object { + "flags": Object { + "default": true, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .servicenow-sir 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "clientSecret": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "password": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "privateKey": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "privateKeyPassword": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "username": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .servicenow-sir 3`] = ` +Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getFields", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getIncident", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "externalId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "handshake", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "pushToService", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comments": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comment": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "commentId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "incident": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "category": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "correlation_display": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "correlation_id": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": "{{rule.id}}:{{alert.id}}", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "description": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "dest_ip": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + ], + "type": "alternatives", + }, + "externalId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "malware_hash": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + ], + "type": "alternatives", + }, + "malware_url": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + ], + "type": "alternatives", + }, + "priority": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "short_description": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "source_ip": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + ], + "type": "alternatives", + }, + "subcategory": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "getChoices", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fields": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + ], + "type": "alternatives", +} +`; + +exports[`Connector type config checks detect connector type changes for: .slack 1`] = ` +Object { + "flags": Object { + "default": Object {}, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .slack 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "webhookUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .slack 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "message": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .slack_api 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "allowedChannels": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + ], + "rules": Array [ + Object { + "args": Object { + "limit": 25, + }, + "name": "max", + }, + ], + "type": "array", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .slack_api 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "token": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .slack_api 3`] = ` +Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "validChannelId", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "channelId": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "postMessage", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "channelIds": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "rules": Array [ + Object { + "args": Object { + "limit": 1, + }, + "name": "max", + }, + ], + "type": "array", + }, + "channels": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "rules": Array [ + Object { + "args": Object { + "limit": 1, + }, + "name": "max", + }, + ], + "type": "array", + }, + "text": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "postBlockkit", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "channelIds": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "rules": Array [ + Object { + "args": Object { + "limit": 1, + }, + "name": "max", + }, + ], + "type": "array", + }, + "channels": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "rules": Array [ + Object { + "args": Object { + "limit": 1, + }, + "name": "max", + }, + ], + "type": "array", + }, + "text": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + ], + "type": "alternatives", +} +`; + +exports[`Connector type config checks detect connector type changes for: .swimlane 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "appId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "connectorType": Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "all", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "alerts", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "cases", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "mappings": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "alertIdConfig": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fieldType": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "caseIdConfig": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fieldType": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "caseNameConfig": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fieldType": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "commentsConfig": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fieldType": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "descriptionConfig": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fieldType": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "ruleNameConfig": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fieldType": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "severityConfig": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "fieldType": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .swimlane 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiToken": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .swimlane 3`] = ` +Object { + "flags": Object { + "error": [Function], + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "allow": Array [ + "pushToService", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comments": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comment": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "commentId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + ], + "type": "array", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "incident": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "alertId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "caseId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "caseName": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "description": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "externalId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "ruleName": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "severity": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + ], + "type": "alternatives", +} +`; + +exports[`Connector type config checks detect connector type changes for: .teams 1`] = ` +Object { + "flags": Object { + "default": Object {}, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .teams 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "webhookUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .teams 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "message": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .tines 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "url": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .tines 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "email": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "token": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .tines 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .torq 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "webhookIntegrationUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .torq 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "token": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .torq 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "body": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .webhook 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "authType": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "webhook-authentication-basic", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "webhook-authentication-ssl", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "ca": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "certType": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "ssl-crt-key", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "ssl-pfx", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "hasAuth": Object { + "flags": Object { + "default": true, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + "headers": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "value": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "name": "entries", + }, + ], + "type": "record", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "method": Object { + "flags": Object { + "default": "post", + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "post", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "put", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "url": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "verificationMode": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "none", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "certificate", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "full", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .webhook 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "crt": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "key": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "password": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "pfx": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "user": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .webhook 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "body": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .xmatters 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "configUrl": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "usesBasic": Object { + "flags": Object { + "default": true, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .xmatters 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "password": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "secretsUrl": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "user": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .xmatters 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "alertActionGroupName": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "date": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "ruleName": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "severity": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "signalId": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "spaceId": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "tags": Object { + "flags": Object { + "default": [Function], + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; diff --git a/x-pack/plugins/actions/server/integration_tests/connector_types.test.ts b/x-pack/plugins/actions/server/integration_tests/connector_types.test.ts new file mode 100644 index 0000000000000..6a2382ea3088f --- /dev/null +++ b/x-pack/plugins/actions/server/integration_tests/connector_types.test.ts @@ -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 type { TestElasticsearchUtils, TestKibanaUtils } from '@kbn/core-test-helpers-kbn-server'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { setupTestServers } from './lib'; +import { connectorTypes } from './mocks/connector_types'; + +jest.mock('../action_type_registry', () => { + const actual = jest.requireActual('../action_type_registry'); + return { + ...actual, + ActionTypeRegistry: jest.fn().mockImplementation((opts) => { + return new actual.ActionTypeRegistry(opts); + }), + }; +}); + +describe('Connector type config checks', () => { + let esServer: TestElasticsearchUtils; + let kibanaServer: TestKibanaUtils; + let actionTypeRegistry: ActionTypeRegistry; + + beforeAll(async () => { + const setupResult = await setupTestServers(); + esServer = setupResult.esServer; + kibanaServer = setupResult.kibanaServer; + + const mockedActionTypeRegistry = jest.requireMock('../action_type_registry'); + expect(mockedActionTypeRegistry.ActionTypeRegistry).toHaveBeenCalledTimes(1); + actionTypeRegistry = mockedActionTypeRegistry.ActionTypeRegistry.mock.results[0].value; + }); + + afterAll(async () => { + if (kibanaServer) { + await kibanaServer.stop(); + } + if (esServer) { + await esServer.stop(); + } + }); + + test('ensure connector types list up to date', () => { + expect(connectorTypes).toEqual(actionTypeRegistry.getAllTypes()); + }); + + for (const connectorTypeId of connectorTypes) { + test(`detect connector type changes for: ${connectorTypeId}`, async () => { + const connectorType = actionTypeRegistry.get(connectorTypeId); + + expect(connectorType?.validate.config.schema.getSchema!().describe()).toMatchSnapshot(); + expect(connectorType.validate.secrets.schema.getSchema!().describe()).toMatchSnapshot(); + expect(connectorType.validate.params.schema.getSchema!().describe()).toMatchSnapshot(); + }); + } +}); diff --git a/x-pack/plugins/actions/server/integration_tests/lib/index.ts b/x-pack/plugins/actions/server/integration_tests/lib/index.ts new file mode 100644 index 0000000000000..c9e6e4c7649bd --- /dev/null +++ b/x-pack/plugins/actions/server/integration_tests/lib/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { setupTestServers } from './setup_test_servers'; diff --git a/x-pack/plugins/actions/server/integration_tests/lib/setup_test_servers.ts b/x-pack/plugins/actions/server/integration_tests/lib/setup_test_servers.ts new file mode 100644 index 0000000000000..4b722d5460213 --- /dev/null +++ b/x-pack/plugins/actions/server/integration_tests/lib/setup_test_servers.ts @@ -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 { createTestServers, createRootWithCorePlugins } from '@kbn/core-test-helpers-kbn-server'; + +export async function setupTestServers(settings = {}) { + const { startES } = createTestServers({ + adjustTimeout: (t) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + }, + }, + }); + + const esServer = await startES(); + + const root = createRootWithCorePlugins(settings, { oss: false }); + + await root.preboot(); + const coreSetup = await root.setup(); + const coreStart = await root.start(); + + return { + esServer, + kibanaServer: { + root, + coreSetup, + coreStart, + stop: async () => await root.shutdown(), + }, + }; +} diff --git a/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts new file mode 100644 index 0000000000000..473f5b72ce59b --- /dev/null +++ b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const connectorTypes: string[] = [ + '.email', + '.index', + '.pagerduty', + '.swimlane', + '.server-log', + '.slack', + '.slack_api', + '.webhook', + '.cases-webhook', + '.xmatters', + '.servicenow', + '.servicenow-sir', + '.servicenow-itom', + '.jira', + '.resilient', + '.teams', + '.torq', + '.opsgenie', + '.tines', + '.gen-ai', + '.bedrock', + '.d3security', + '.sentinelone', +]; diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 343d9b3dde4f6..e2ef2675a59e9 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -16,6 +16,7 @@ import { SavedObjectReference, Logger, } from '@kbn/core/server'; +import { AnySchema } from 'joi'; import { ActionTypeRegistry } from './action_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { ActionsClient } from './actions_client'; @@ -101,11 +102,12 @@ export type ExecutorType< options: ActionTypeExecutorOptions ) => Promise>; -export interface ValidatorType { +export interface ValidatorType { schema: { - validate(value: unknown): Type; + validate(value: unknown): T; + getSchema?: () => AnySchema; }; - customValidator?: (value: Type, validatorServices: ValidatorServices) => void; + customValidator?: (value: T, validatorServices: ValidatorServices) => void; } export interface ValidatorServices { diff --git a/x-pack/plugins/actions/tsconfig.json b/x-pack/plugins/actions/tsconfig.json index 63c296d2d35e0..92a1074c95115 100644 --- a/x-pack/plugins/actions/tsconfig.json +++ b/x-pack/plugins/actions/tsconfig.json @@ -44,7 +44,8 @@ "@kbn/core-elasticsearch-server-mocks", "@kbn/core-logging-server-mocks", "@kbn/serverless", - "@kbn/actions-types" + "@kbn/actions-types", + "@kbn/core-test-helpers-kbn-server" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/alerting/server/rules_client/methods/get_alert_state.ts b/x-pack/plugins/alerting/server/rules_client/methods/get_alert_state.ts index 6497428e1c2f2..4da913a06fe79 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/get_alert_state.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/get_alert_state.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import { RuleTaskState } from '../../types'; import { taskInstanceToAlertTaskInstance } from '../../task_runner/alert_task_instance'; import { ReadOperations, AlertingAuthorizationEntity } from '../../authorization'; @@ -18,18 +19,28 @@ export async function getAlertState( context: RulesClientContext, { id }: GetAlertStateParams ): Promise { - const alert = await get(context, { id }); + const rule = await get(context, { id }); await context.authorization.ensureAuthorized({ - ruleTypeId: alert.alertTypeId, - consumer: alert.consumer, + ruleTypeId: rule.alertTypeId, + consumer: rule.consumer, operation: ReadOperations.GetRuleState, entity: AlertingAuthorizationEntity.Rule, }); - if (alert.scheduledTaskId) { - const { state } = taskInstanceToAlertTaskInstance( - await context.taskManager.get(alert.scheduledTaskId), - alert - ); - return state; + if (rule.scheduledTaskId) { + try { + const { state } = taskInstanceToAlertTaskInstance( + await context.taskManager.get(rule.scheduledTaskId), + rule + ); + return state; + } catch (e) { + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + context.logger.warn(`Task (${rule.scheduledTaskId}) not found`); + } else { + context.logger.warn( + `An error occurred when getting the task state for (${rule.scheduledTaskId})` + ); + } + } } } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts index 63d86843512c0..951e85c023523 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts @@ -22,6 +22,7 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { getBeforeSetup } from './lib'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -175,6 +176,70 @@ describe('getAlertState()', () => { expect(taskManager.get).toHaveBeenCalledWith(scheduledTaskId); }); + test('logs a warning if the task not found', async () => { + const rulesClient = new RulesClient(rulesClientParams); + + const scheduledTaskId = 'task-123'; + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: RULE_SAVED_OBJECT_TYPE, + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + enabled: true, + scheduledTaskId, + mutedInstanceIds: [], + muteAll: true, + }, + references: [], + }); + + taskManager.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError()); + + await rulesClient.getAlertState({ id: '1' }); + + expect(rulesClientParams.logger.warn).toHaveBeenCalledTimes(1); + expect(rulesClientParams.logger.warn).toHaveBeenCalledWith('Task (task-123) not found'); + }); + + test('logs a warning if the taskManager throws an error', async () => { + const rulesClient = new RulesClient(rulesClientParams); + + const scheduledTaskId = 'task-123'; + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: RULE_SAVED_OBJECT_TYPE, + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + enabled: true, + scheduledTaskId, + mutedInstanceIds: [], + muteAll: true, + }, + references: [], + }); + + taskManager.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createBadRequestError()); + + await rulesClient.getAlertState({ id: '1' }); + + expect(rulesClientParams.logger.warn).toHaveBeenCalledTimes(1); + expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( + 'An error occurred when getting the task state for (task-123)' + ); + }); + describe('authorization', () => { beforeEach(() => { unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ From 912c260108dddd78c574ec4cd6f9f19331f2b0ac Mon Sep 17 00:00:00 2001 From: "Quynh Nguyen (Quinn)" <43350163+qn895@users.noreply.github.com> Date: Thu, 15 Feb 2024 12:11:43 -0600 Subject: [PATCH 13/23] [ML] Fixes Single Metric Viewer's zoom settings in URL are not restored if URL specifies a forecast ID (#176969) ## Summary Fix https://github.com/elastic/kibana/issues/168583 After https://github.com/elastic/kibana/assets/43350163/9fd1f43a-ca70-4495-b872-57cbcf421db9 ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/timeseriesexplorer/timeseriesexplorer.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 76c0ffb038971..cb1f18fd82358 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -447,9 +447,13 @@ export class TimeSeriesExplorer extends React.Component { stateUpdate.contextAggregationInterval, bounds ); + if ( - focusRange === undefined || - this.previousSelectedForecastId !== this.props.selectedForecastId + // If the user's focus range is not defined (i.e. no 'zoom' parameter restored from the appState URL), + // then calculate the default focus range to use + zoom === undefined && + (focusRange === undefined || + this.previousSelectedForecastId !== this.props.selectedForecastId) ) { focusRange = calculateDefaultFocusRange( autoZoomDuration, From f5e3b942db8ee43b135feba7c068c1fc4ae80111 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 15 Feb 2024 14:12:06 -0400 Subject: [PATCH 14/23] Fix flaky test from #173292 and #173784 (#176978) ## Summary This PR fixes the flaky test from #173292 and #173784. Flaky test runner x95: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5193. Resolves #173292. Resolves #173784. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: Julia Rechkunova --- .../use_fetch_occurances_range.ts | 5 ++++- test/functional/apps/discover/group1/_discover.ts | 7 +++++-- .../test_suites/common/discover/group1/_discover.ts | 11 ++++++----- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/plugins/discover/public/application/main/components/no_results/no_results_suggestions/use_fetch_occurances_range.ts b/src/plugins/discover/public/application/main/components/no_results/no_results_suggestions/use_fetch_occurances_range.ts index 90efad593d519..e5306344f1058 100644 --- a/src/plugins/discover/public/application/main/components/no_results/no_results_suggestions/use_fetch_occurances_range.ts +++ b/src/plugins/discover/public/application/main/components/no_results/no_results_suggestions/use_fetch_occurances_range.ts @@ -66,7 +66,10 @@ export const useFetchOccurrencesRange = (params: Params): Result => { abortSignal: abortControllerRef.current?.signal, }); } catch (error) { - // + if (error.name !== 'AbortError') { + // eslint-disable-next-line no-console + console.error(error); + } } } diff --git a/test/functional/apps/discover/group1/_discover.ts b/test/functional/apps/discover/group1/_discover.ts index 0210c7d8cc7f2..34679964e3c94 100644 --- a/test/functional/apps/discover/group1/_discover.ts +++ b/test/functional/apps/discover/group1/_discover.ts @@ -170,8 +170,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show matches when time range is expanded', async () => { - await PageObjects.discover.expandTimeRangeAsSuggestedInNoResultsMessage(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await retry.waitFor('view all matches to load', async () => { + await PageObjects.discover.expandTimeRangeAsSuggestedInNoResultsMessage(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + return !(await testSubjects.exists('discoverNoResultsViewAllMatches')); + }); await retry.try(async function () { expect(await PageObjects.discover.hasNoResults()).to.be(false); expect(await PageObjects.discover.getHitCountInt()).to.be.above(0); diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts index 1b5b3c8f6ff52..32fa8d5d6a8af 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts @@ -152,9 +152,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/173292 - // FLAKY: https://github.com/elastic/kibana/issues/173784 - describe.skip('query #2, which has an empty time range', () => { + describe('query #2, which has an empty time range', () => { const fromTime = 'Jun 11, 1999 @ 09:22:11.000'; const toTime = 'Jun 12, 1999 @ 11:21:04.000'; @@ -177,8 +175,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show matches when time range is expanded', async () => { - await PageObjects.discover.expandTimeRangeAsSuggestedInNoResultsMessage(); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await retry.waitFor('view all matches to load', async () => { + await PageObjects.discover.expandTimeRangeAsSuggestedInNoResultsMessage(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + return !(await testSubjects.exists('discoverNoResultsViewAllMatches')); + }); await retry.try(async function () { expect(await PageObjects.discover.hasNoResults()).to.be(false); expect(await PageObjects.discover.getHitCountInt()).to.be.above(0); From e84128e147744bbdfaff8667332763cbc5bafad5 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Thu, 15 Feb 2024 19:13:34 +0100 Subject: [PATCH 15/23] Better styling for connectors (#177048) --- .../public/routes/components/settings_tab.tsx | 1 + .../connector_selector_base.tsx | 25 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.tsx b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.tsx index ab68cb3abb306..f0a5650d41f70 100644 --- a/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.tsx +++ b/src/plugins/ai_assistant_management/observability/public/routes/components/settings_tab.tsx @@ -162,6 +162,7 @@ export function SettingsTab() { )} > - + ({ value: connector.id, inputDisplay: ( - + {i18n.translate( @@ -102,18 +103,16 @@ export function ConnectorSelectorBase(props: ConnectorSelectorBaseProps) { )} - - - {connector.name} - + + ), - dropdownDisplay: ( - - {connector.name} - - ), + dropdownDisplay: {connector.name}, }))} onChange={(id) => { props.selectConnector(id); From 167cc236b6d397020a0c8847cb5154ad4e2b341d Mon Sep 17 00:00:00 2001 From: Marta Bondyra <4283304+mbondyra@users.noreply.github.com> Date: Thu, 15 Feb 2024 19:14:45 +0100 Subject: [PATCH 16/23] [Lens] fix tests console errors (#176506) ## Summary Removes these console.errors from when we run tests. Rewrites all chart_switch and almost all editor frame tests to react testing library. Slightly improves some mocks. Screenshot 2024-02-15 at 11 13 10 Screenshot 2024-02-15 at 11 13 24 Screenshot 2024-02-15 at 11 13 30 Screenshot 2024-02-15 at 11 13 36 Screenshot 2024-02-15 at 11 13 45 --- .../datasources/common/field_item.test.tsx | 3 +- .../dimension_panel/dimension_panel.test.tsx | 117 +- .../datasources/form_based/utils.test.tsx | 6 +- .../editor_frame/data_panel_wrapper.tsx | 4 +- .../editor_frame/editor_frame.test.tsx | 1024 +++++++---------- .../workspace_panel/chart_switch.test.tsx | 599 +++------- .../workspace_panel/chart_switch.tsx | 6 +- .../lens/public/mocks/datasource_mock.tsx | 2 +- .../public/mocks/expression_renderer_mock.tsx | 4 +- .../plugins/lens/public/mocks/store_mocks.tsx | 2 +- .../lens/public/mocks/visualization_mock.tsx | 29 +- .../__snapshots__/load_initial.test.tsx.snap | 4 +- .../state_management/load_initial.test.tsx | 2 +- 13 files changed, 685 insertions(+), 1117 deletions(-) diff --git a/x-pack/plugins/lens/public/datasources/common/field_item.test.tsx b/x-pack/plugins/lens/public/datasources/common/field_item.test.tsx index 72686f0faf69f..afd178a64ece5 100644 --- a/x-pack/plugins/lens/public/datasources/common/field_item.test.tsx +++ b/x-pack/plugins/lens/public/datasources/common/field_item.test.tsx @@ -140,7 +140,8 @@ describe('Lens Field Item', () => { }, documentField, ], - } as IndexPattern; + isTimeBased: jest.fn(), + } as unknown as IndexPattern; defaultProps = { indexPattern, diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.test.tsx index 86ac619e9dcaa..dedd7c2f38ef5 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.test.tsx @@ -7,7 +7,8 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme'; import React, { ChangeEvent } from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, act, render, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { findTestSubject } from '@elastic/eui/lib/test'; import { EuiComboBox, @@ -282,6 +283,30 @@ describe('FormBasedDimensionEditor', () => { jest.clearAllMocks(); }); + const renderDimensionPanel = (propsOverrides = {}) => { + const Wrapper: React.FC<{ + children: React.ReactNode; + }> = ({ children }) => { + return {children}; + }; + + const rtlRender = render( + , + { + wrapper: Wrapper, + } + ); + + const getVisibleFieldSelectOptions = () => { + const optionsList = screen.getByRole('dialog'); + return within(optionsList) + .getAllByRole('option') + .map((option) => option.textContent); + }; + + return { ...rtlRender, getVisibleFieldSelectOptions }; + }; + let wrapper: ReactWrapper | ShallowWrapper; afterEach(() => { @@ -293,71 +318,67 @@ describe('FormBasedDimensionEditor', () => { it('should call the filterOperations function', () => { const filterOperations = jest.fn().mockReturnValue(true); - wrapper = mountWithServices( - - ); - + renderDimensionPanel({ filterOperations }); expect(filterOperations).toBeCalled(); }); it('should show field select', () => { - wrapper = mountWithServices(); - - expect(getFieldSelectComboBox(wrapper)).toHaveLength(1); + renderDimensionPanel(); + expect(screen.getByTestId('indexPattern-dimension-field')).toBeInTheDocument(); }); it('should not show field select on fieldless operation', () => { - wrapper = mountWithServices( - - ); + renderDimensionPanel({ + state: getStateWithColumns({ + col1: { + label: 'Filters', + dataType: 'string', + isBucketed: false, - expect(getFieldSelectComboBox(wrapper)).toHaveLength(0); + // Private + operationType: 'filters', + params: { filters: [] }, + } as FiltersIndexPatternColumn, + }), + }); + expect(screen.queryByTestId('indexPattern-dimension-field')).not.toBeInTheDocument(); }); it('should not show any choices if the filter returns false', () => { - wrapper = mountWithServices( - false} - /> - ); - - expect(getFieldSelectComboBox(wrapper).prop('options')!).toHaveLength(0); + renderDimensionPanel({ + columnId: 'col2', + filterOperations: () => false, + }); + userEvent.click(screen.getByRole('button', { name: /open list of options/i })); + expect(screen.getByText(/There aren't any options available/)).toBeInTheDocument(); }); it('should list all field names and document as a whole in prioritized order', () => { - wrapper = mountWithServices(); - - const options = getFieldSelectComboBox(wrapper).prop('options'); + const { getVisibleFieldSelectOptions } = renderDimensionPanel(); - expect(options).toHaveLength(3); + const comboBoxButton = screen.getAllByRole('button', { name: /open list of options/i })[0]; + const comboBoxInput = screen.getAllByTestId('comboBoxSearchInput')[0]; + userEvent.click(comboBoxButton); - expect(options![0].label).toEqual('Records'); - expect(options![1].options!.map(({ label }) => label)).toEqual([ + const allOptions = [ + 'Records', 'timestampLabel', 'bytes', 'memory', 'source', - ]); + // these fields are generated to test the issue #148062 about fields that are using JS Object method names + ...Object.getOwnPropertyNames(Object.getPrototypeOf({})).sort(), + ]; + expect(allOptions.slice(0, 7)).toEqual(getVisibleFieldSelectOptions()); - // these fields are generated to test the issue #148062 about fields that are using JS Object method names - expect(options![2].options!.map(({ label }) => label)).toEqual( - Object.getOwnPropertyNames(Object.getPrototypeOf({})).sort() - ); + // keep hitting arrow down to scroll to the next options (react-window only renders visible options) + userEvent.type(comboBoxInput, '{ArrowDown}'.repeat(12)); + + expect(getVisibleFieldSelectOptions()).toEqual(allOptions.slice(5, 16)); + + // press again to go back to the beginning + userEvent.type(comboBoxInput, '{ArrowDown}'); + expect(getVisibleFieldSelectOptions()).toEqual(allOptions.slice(0, 9)); }); it('should hide fields that have no data', () => { @@ -1783,7 +1804,11 @@ describe('FormBasedDimensionEditor', () => { }); expect(setState.mock.calls[0]).toEqual([expect.any(Function), { isDimensionComplete: true }]); - expect(setState.mock.calls[0][0](props.state)).toEqual({ + let newState = props.state; + act(() => { + newState = setState.mock.calls[0][0](props.state); + }); + expect(newState).toEqual({ ...props.state, layers: { first: { diff --git a/x-pack/plugins/lens/public/datasources/form_based/utils.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/utils.test.tsx index 7d7b2d9b59f83..3302aac2a5579 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/utils.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/utils.test.tsx @@ -310,7 +310,11 @@ describe('indexpattern_datasource utils', () => { ]), [ `${rootId}X${formulaParts.length}`, - { operationType: 'math', references: formulaParts.map((_, i) => `${rootId}X${i}`) }, + { + operationType: 'math', + references: formulaParts.map((_, i) => `${rootId}X${i}`), + label: 'Part of formula', + }, ], ]); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index b9ad0df04aaa3..da78db7ed0bc6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -194,7 +194,9 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { <> {DataPanelComponent && ( -
{DataPanelComponent(datasourceProps)}
+
+ {DataPanelComponent(datasourceProps)} +
)} ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 7cd48d6beb03d..d99f6418870fa 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -7,31 +7,18 @@ import React, { useEffect } from 'react'; import { ReactWrapper } from 'enzyme'; -import faker from 'faker'; - -// Tests are executed in a jsdom environment who does not have sizing methods, -// thus the AutoSizer will always compute a 0x0 size space -// Mock the AutoSizer inside EuiSelectable (Chart Switch) and return some dimensions > 0 -jest.mock('react-virtualized-auto-sizer', () => { - return function (props: { - children: (dimensions: { width: number; height: number }) => React.ReactNode; - disableHeight?: boolean; - }) { - const { children, disableHeight, ...otherProps } = props; - return ( - // js-dom may complain that a non-DOM attributes are used when appending props - // Handle the disableHeight case using native DOM styling -
- {children({ width: 100, height: 100 })} -
- ); - }; -}); +import { screen, fireEvent, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; -import { EuiPanel, EuiToolTip } from '@elastic/eui'; import { EditorFrame, EditorFrameProps } from './editor_frame'; -import { DatasourcePublicAPI, DatasourceSuggestion, Visualization } from '../../types'; -import { act } from 'react-dom/test-utils'; +import { + DatasourceMap, + DatasourcePublicAPI, + DatasourceSuggestion, + Visualization, + VisualizationMap, +} from '../../types'; +import { act } from '@testing-library/react'; import { coreMock } from '@kbn/core/public/mocks'; import { createMockVisualization, @@ -42,15 +29,13 @@ import { renderWithReduxStore, } from '../../mocks'; import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks'; -import { ReactExpressionRendererType } from '@kbn/expressions-plugin/public'; import { DragDrop, useDragDropContext } from '@kbn/dom-drag-drop'; import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks'; import { mockDataPlugin, mountWithProvider } from '../../mocks'; -import { setState } from '../../state_management'; +import { LensAppState, setState } from '../../state_management'; import { getLensInspectorService } from '../../lens_inspector_service'; -import { toExpression } from '@kbn/interpreter'; import { createIndexPatternServiceMock } from '../../mocks/data_views_service_mock'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public'; @@ -105,6 +90,7 @@ function getDefaultProps() { indexPatternService: createIndexPatternServiceMock(), getUserMessages: () => [], addUserMessages: () => () => {}, + ExpressionRenderer: createExpressionRendererMock(), }; return defaultProps; } @@ -116,207 +102,172 @@ describe('editor_frame', () => { let mockVisualization2: jest.Mocked; let mockDatasource2: DatasourceMock; - let expressionRendererMock: ReactExpressionRendererType; + let visualizationMap: VisualizationMap; + let datasourceMap: DatasourceMap; beforeEach(() => { - mockVisualization = { - ...createMockVisualization(), - id: 'testVis', - visualizationTypes: [ - { - icon: 'empty', - id: 'testVis', - label: faker.lorem.word(), - groupLabel: 'testVisGroup', - }, - ], - }; - mockVisualization2 = { - ...createMockVisualization(), - id: 'testVis2', - visualizationTypes: [ - { - icon: 'empty', - id: 'testVis2', - label: 'TEST2', - groupLabel: 'testVis2Group', - }, - ], - }; - - mockVisualization.getLayerIds.mockReturnValue(['first']); - mockVisualization2.getLayerIds.mockReturnValue(['second']); + mockVisualization = createMockVisualization(); + mockVisualization2 = createMockVisualization('testVis2', ['second']); mockDatasource = createMockDatasource(); mockDatasource2 = createMockDatasource('testDatasource2'); + mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { + state: {}, + table: { + columns: [], + isMultiRow: true, + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: [], + }, + ]); + + visualizationMap = { + testVis: mockVisualization, + testVis2: mockVisualization2, + }; - expressionRendererMock = createExpressionRendererMock(); + datasourceMap = { + testDatasource: mockDatasource, + testDatasource2: mockDatasource2, + }; }); - describe('initialization', () => { - it('should not render something before all datasources are initialized', async () => { - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - }; - const { lensStore } = await mountWithProvider(, { + const renderEditorFrame = ( + propsOverrides: Partial = {}, + { preloadedStateOverrides }: { preloadedStateOverrides: Partial } = { + preloadedStateOverrides: {}, + } + ) => { + const { store, ...rtlRender } = renderWithReduxStore( + , + {}, + { preloadedState: { activeDatasourceId: 'testDatasource', - datasourceStates: { - testDatasource: { - isLoading: true, - state: { - internalState1: '', - }, - }, - }, - }, - }); - expect(mockDatasource.DataPanelComponent).not.toHaveBeenCalled(); - lensStore.dispatch( - setState({ + visualization: { activeId: mockVisualization.id, state: 'initialState' }, datasourceStates: { testDatasource: { isLoading: false, state: { - internalState1: '', + internalState: 'datasourceState', }, }, }, - }) - ); - expect(mockDatasource.DataPanelComponent).toHaveBeenCalled(); - }); - - it('should initialize visualization state and render config panel', async () => { - const initialState = {}; - mockDatasource.getLayers.mockReturnValue(['first']); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: { ...mockVisualization, initialize: () => initialState }, - }, - datasourceMap: { - testDatasource: { - ...mockDatasource, - initialize: () => Promise.resolve(), - }, + ...preloadedStateOverrides, }, + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), + } + ); - ExpressionRenderer: expressionRendererMock, - }; + const openChartSwitch = () => { + userEvent.click(screen.getByTestId('lnsChartSwitchPopover')); + }; - await mountWithProvider(, { - preloadedState: { - visualization: { activeId: 'testVis', state: initialState }, - }, + const waitForChartSwitchClosed = () => { + waitFor(() => { + expect(screen.queryByTestId('lnsChartSwitchList')).not.toBeInTheDocument(); }); + }; - expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( - expect.objectContaining({ state: initialState }) - ); - }); - - let instance: ReactWrapper; - - it('should render the resulting expression using the expression renderer', async () => { - mockDatasource.getLayers.mockReturnValue(['first']); - - const props: EditorFrameProps = { - ...getDefaultProps(), - visualizationMap: { - testVis: { - ...mockVisualization, - toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => - toExpression({ - type: 'expression', - chain: [ - ...(datasourceExpressionsByLayers.first?.chain ?? []), - { type: 'function', function: 'testVis', arguments: {} }, - ], - }), - }, - }, - datasourceMap: { - testDatasource: { - ...mockDatasource, - toExpression: () => 'datasource', - }, - }, + const getMenuItem = (subType: string) => { + const list = screen.getByTestId('lnsChartSwitchList'); + return within(list).getByTestId(`lnsChartSwitchPopover_${subType}`); + }; - ExpressionRenderer: expressionRendererMock, - }; - instance = ( - await mountWithProvider(, { - preloadedState: { - visualization: { activeId: 'testVis', state: {} }, + const switchToVis = (subType: string) => { + fireEvent.click(getMenuItem(subType)); + }; + const queryLayerPanel = () => screen.queryByTestId('lns-layerPanel-0'); + const queryWorkspacePanel = () => screen.queryByTestId('lnsWorkspace'); + const queryDataPanel = () => screen.queryByTestId('lnsDataPanelWrapper'); + + return { + ...rtlRender, + store, + switchToVis, + getMenuItem, + openChartSwitch, + queryLayerPanel, + queryWorkspacePanel, + queryDataPanel, + waitForChartSwitchClosed, + simulateLoadingDatasource: () => + store.dispatch( + setState({ datasourceStates: { testDatasource: { isLoading: false, state: { - internalState1: '', + internalState: 'datasourceState', + }, + }, + }, + }) + ), + }; + }; + + describe('initialization', () => { + it('should render workspace panel, data panel and layer panel when all datasources are initialized', async () => { + const { queryWorkspacePanel, queryDataPanel, queryLayerPanel, simulateLoadingDatasource } = + renderEditorFrame(undefined, { + preloadedStateOverrides: { + datasourceStates: { + testDatasource: { + isLoading: true, + state: { + internalState: 'datasourceState', }, }, }, }, - }) - ).instance; + }); - instance.update(); + expect(mockVisualization.getConfiguration).not.toHaveBeenCalled(); + expect(queryWorkspacePanel()).not.toBeInTheDocument(); + expect(queryDataPanel()).not.toBeInTheDocument(); + expect(queryLayerPanel()).not.toBeInTheDocument(); - expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` - "datasource - | testVis" - `); + simulateLoadingDatasource(); + expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ state: 'initialState' }) + ); + + expect(queryWorkspacePanel()).toBeInTheDocument(); + expect(queryDataPanel()).toBeInTheDocument(); + expect(queryLayerPanel()).toBeInTheDocument(); + }); + it('should render the resulting expression using the expression renderer', async () => { + renderEditorFrame(); + expect(screen.getByTestId('lnsExpressionRenderer')).toHaveTextContent( + 'datasource_expression | testVis' + ); }); }); describe('state update', () => { it('should re-render config panel after state update', async () => { - mockDatasource.getLayers.mockReturnValue(['first']); - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: { ...mockVisualization, toExpression: () => null }, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, + const { store } = renderEditorFrame(); + const updatedState = 'updatedVisState'; - ExpressionRenderer: expressionRendererMock, - }; - renderWithReduxStore( - , - {}, - { - preloadedState: { - activeDatasourceId: 'testDatasource', - visualization: { activeId: mockVisualization.id, state: {} }, - datasourceStates: { - testDatasource: { - isLoading: false, - state: '', - }, - }, + store.dispatch( + setState({ + visualization: { + activeId: mockVisualization.id, + state: updatedState, }, - } + }) ); - const updatedState = {}; - const setDatasourceState = (mockDatasource.DataPanelComponent as jest.Mock).mock.calls[0][0] - .setState; - act(() => { - setDatasourceState(updatedState); - }); - expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(3); expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -326,20 +277,7 @@ describe('editor_frame', () => { }); it('should re-render data panel after state update', async () => { - mockDatasource.getLayers.mockReturnValue(['first']); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await mountWithProvider(); + renderEditorFrame(); const setDatasourceState = (mockDatasource.DataPanelComponent as jest.Mock).mock.calls[0][0] .setState; @@ -349,9 +287,8 @@ describe('editor_frame', () => { const updatedState = { title: 'shazm', }; - act(() => { - setDatasourceState(updatedState); - }); + + setDatasourceState(updatedState); expect(mockDatasource.DataPanelComponent).toHaveBeenCalledTimes(1); expect(mockDatasource.DataPanelComponent).toHaveBeenLastCalledWith( @@ -362,21 +299,7 @@ describe('editor_frame', () => { }); it('should re-render config panel with updated datasource api after datasource state update', async () => { - mockDatasource.getLayers.mockReturnValue(['first']); - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await mountWithProvider(, { - preloadedState: { visualization: { activeId: mockVisualization.id, state: {} } }, - }); + renderEditorFrame(); const updatedPublicAPI: DatasourcePublicAPI = { datasourceId: 'testDatasource', @@ -394,9 +317,8 @@ describe('editor_frame', () => { const setDatasourceState = (mockDatasource.DataPanelComponent as jest.Mock).mock.calls[0][0] .setState; - act(() => { - setDatasourceState('newState'); - }); + + setDatasourceState('newState'); expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(1); expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( @@ -413,34 +335,10 @@ describe('editor_frame', () => { describe('datasource public api communication', () => { it('should give access to the datasource state in the datasource factory function', async () => { - const datasourceState = {}; - mockDatasource.initialize.mockReturnValue(datasourceState); - mockDatasource.getLayers.mockReturnValue(['first']); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await mountWithProvider(, { - preloadedState: { - datasourceStates: { - testDatasource: { - isLoading: false, - state: {}, - }, - }, - }, - }); + renderEditorFrame(); expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith({ - state: datasourceState, + state: { internalState: 'datasourceState' }, layerId: 'first', indexPatterns: {}, }); @@ -448,75 +346,15 @@ describe('editor_frame', () => { }); describe('switching', () => { - let instance: ReactWrapper; - - function switchTo(subType: string) { - act(() => { - instance.find('[data-test-subj="lnsChartSwitchPopover"]').last().simulate('click'); - }); - - instance.update(); - - act(() => { - instance - .find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`) - .last() - .simulate('click'); - }); - } - - beforeEach(async () => { - mockVisualization2.initialize.mockReturnValue({ initial: true }); - mockDatasource.getLayers.mockReturnValue(['first', 'second']); - mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ - { - state: {}, - table: { - columns: [], - isMultiRow: true, - layerId: 'first', - changeType: 'unchanged', - }, - keptLayerIds: [], - }, - ]); - - const visualizationMap = { - testVis: mockVisualization, - testVis2: mockVisualization2, - }; - - const datasourceMap = { - testDatasource: mockDatasource, - testDatasource2: mockDatasource2, - }; - - const props = { - ...getDefaultProps(), - visualizationMap, - datasourceMap, - ExpressionRenderer: expressionRendererMock, - }; - instance = ( - await mountWithProvider(, { - storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), - }) - ).instance; - - // necessary to flush elements to dom synchronously - instance.update(); - }); - - afterEach(() => { - instance.unmount(); - }); - it('should initialize other visualization on switch', async () => { - switchTo('testVis2'); + const { openChartSwitch, switchToVis } = renderEditorFrame(); + openChartSwitch(); + switchToVis('testVis2'); expect(mockVisualization2.initialize).toHaveBeenCalled(); }); it('should use suggestions to switch to new visualization', async () => { + const { openChartSwitch, switchToVis } = renderEditorFrame(); const initialState = { suggested: true }; mockVisualization2.initialize.mockReturnValueOnce({ initial: true }); mockVisualization2.getVisualizationTypeId.mockReturnValueOnce('testVis2'); @@ -528,9 +366,8 @@ describe('editor_frame', () => { previewIcon: 'empty', }, ]); - - switchTo('testVis2'); - + openChartSwitch(); + switchToVis('testVis2'); expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.initialize).toHaveBeenCalledWith(expect.anything(), initialState); expect(mockVisualization2.getConfiguration).toHaveBeenCalledWith( @@ -541,7 +378,9 @@ describe('editor_frame', () => { it('should fall back when switching visualizations if the visualization has no suggested use', async () => { mockVisualization2.initialize.mockReturnValueOnce({ initial: true }); - switchTo('testVis2'); + const { openChartSwitch, switchToVis, waitForChartSwitchClosed } = renderEditorFrame(); + openChartSwitch(); + switchToVis('testVis2'); expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled(); expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); @@ -553,131 +392,80 @@ describe('editor_frame', () => { expect(mockVisualization2.getConfiguration).toHaveBeenCalledWith( expect.objectContaining({ state: { initial: true } }) ); + waitForChartSwitchClosed(); }); }); describe('suggestions', () => { it('should fetch suggestions of currently active datasource', async () => { - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - testDatasource2: mockDatasource2, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await mountWithProvider(); - + renderEditorFrame(); expect(mockDatasource.getDatasourceSuggestionsFromCurrentState).toHaveBeenCalled(); expect(mockDatasource2.getDatasourceSuggestionsFromCurrentState).not.toHaveBeenCalled(); }); it('should fetch suggestions of all visualizations', async () => { - mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ - { - state: {}, - table: { - changeType: 'unchanged', - columns: [], - isMultiRow: true, - layerId: 'first', - }, - keptLayerIds: [], - }, - ]); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - testVis2: mockVisualization2, - }, - datasourceMap: { - testDatasource: mockDatasource, - testDatasource2: mockDatasource2, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await mountWithProvider(); + renderEditorFrame(); expect(mockVisualization.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); }); - let instance: ReactWrapper; it('should display top 5 suggestions in descending order', async () => { - mockDatasource.getLayers.mockReturnValue(['first']); - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: { - ...mockVisualization, - getSuggestions: () => [ - { - score: 0.1, - state: {}, - title: 'Suggestion6', - previewIcon: 'empty', - }, - { - score: 0.5, - state: {}, - title: 'Suggestion3', - previewIcon: 'empty', - }, - { - score: 0.7, - state: {}, - title: 'Suggestion2', - previewIcon: 'empty', - }, - { - score: 0.8, - state: {}, - title: 'Suggestion1', - previewIcon: 'empty', - }, - ], - }, - testVis2: { - ...mockVisualization, - getSuggestions: () => [ - { - score: 0.4, - state: {}, - title: 'Suggestion5', - previewIcon: 'empty', - }, - { - score: 0.45, - state: {}, - title: 'Suggestion4', - previewIcon: 'empty', - }, - ], - }, + visualizationMap = { + testVis: { + ...mockVisualization, + getSuggestions: () => [ + { + score: 0.1, + state: {}, + title: 'Suggestion6', + previewIcon: 'empty', + }, + { + score: 0.5, + state: {}, + title: 'Suggestion3', + previewIcon: 'empty', + }, + { + score: 0.7, + state: {}, + title: 'Suggestion2', + previewIcon: 'empty', + }, + { + score: 0.8, + state: {}, + title: 'Suggestion1', + previewIcon: 'empty', + }, + ], }, - datasourceMap: { - testDatasource: { - ...mockDatasource, - getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], - }, + testVis2: { + ...mockVisualization, + getSuggestions: () => [ + { + score: 0.4, + state: {}, + title: 'Suggestion5', + previewIcon: 'empty', + }, + { + score: 0.45, + state: {}, + title: 'Suggestion4', + previewIcon: 'empty', + }, + ], }, - - ExpressionRenderer: expressionRendererMock, }; - instance = (await mountWithProvider()).instance; + + renderEditorFrame(); expect( - instance - .find('[data-test-subj="lnsSuggestion"]') - .find(EuiPanel) - .map((el) => el.parents(EuiToolTip).prop('content')) + within(screen.getByTestId('lnsSuggestionsPanel')) + .getAllByTestId('lnsSuggestion') + .map((el) => el.textContent) ).toEqual([ 'Current visualization', 'Suggestion1', @@ -691,39 +479,26 @@ describe('editor_frame', () => { it('should switch to suggested visualization', async () => { mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); const newDatasourceState = {}; - const suggestionVisState = {}; - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: { - ...mockVisualization, - getSuggestions: () => [ - { - score: 0.8, - state: suggestionVisState, - title: 'Suggestion1', - previewIcon: 'empty', - }, - ], - }, - testVis2: mockVisualization2, - }, - datasourceMap: { - testDatasource: { - ...mockDatasource, - getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], - }, + const suggestionVisState = { suggested: true }; + + visualizationMap = { + testVis: { + ...mockVisualization, + getSuggestions: () => [ + { + score: 0.8, + state: suggestionVisState, + title: 'Suggestion1', + previewIcon: 'empty', + }, + ], }, - - ExpressionRenderer: expressionRendererMock, + testVis2: mockVisualization2, }; - instance = (await mountWithProvider()).instance; - act(() => { - instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); - }); + renderEditorFrame(); + userEvent.click(screen.getByLabelText(/Suggestion1/i)); - expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(2); expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith( expect.objectContaining({ state: suggestionVisState, @@ -735,13 +510,18 @@ describe('editor_frame', () => { }) ); }); + describe('legacy tests', () => { + let instance: ReactWrapper; + + afterEach(() => { + instance.unmount(); + }); + + // this test doesn't test anything, it's buggy and should be rewritten when we find a way to user test drag and drop + it.skip('should switch to best suggested visualization on field drop', async () => { + const suggestionVisState = {}; - it('should switch to best suggested visualization on field drop', async () => { - mockDatasource.getLayers.mockReturnValue(['first']); - const suggestionVisState = {}; - const props = { - ...getDefaultProps(), - visualizationMap: { + visualizationMap = { testVis: { ...mockVisualization, getSuggestions: () => [ @@ -760,210 +540,196 @@ describe('editor_frame', () => { ], }, testVis2: mockVisualization2, - }, - datasourceMap: { + }; + datasourceMap = { testDatasource: { ...mockDatasource, getDatasourceSuggestionsForField: () => [generateSuggestion()], getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], }, - }, + }; + renderEditorFrame(); - ExpressionRenderer: expressionRendererMock, - }; - instance = (await mountWithProvider()).instance; + mockVisualization.getConfiguration.mockClear(); + act(() => { + instance.find('[data-test-subj="lnsWorkspace"]').last().simulate('drop'); + }); - act(() => { - instance.find('[data-test-subj="lnsWorkspace"]').last().simulate('drop'); + expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + state: {}, + }) + ); }); - expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( - expect.objectContaining({ - state: suggestionVisState, - }) - ); - }); - - it('should use the currently selected visualization if possible on field drop', async () => { - mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); - const suggestionVisState = {}; - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: { - ...mockVisualization, - getSuggestions: () => [ - { - score: 0.2, - state: {}, - title: 'Suggestion1', - previewIcon: 'empty', - }, - { - score: 0.6, - state: suggestionVisState, - title: 'Suggestion2', - previewIcon: 'empty', - }, - ], - }, - testVis2: { - ...mockVisualization2, - getSuggestions: () => [ - { - score: 0.8, - state: {}, - title: 'Suggestion3', - previewIcon: 'empty', - }, - ], + it('should use the currently selected visualization if possible on field drop', async () => { + mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); + const suggestionVisState = {}; + const props = { + ...getDefaultProps(), + visualizationMap: { + testVis: { + ...mockVisualization, + getSuggestions: () => [ + { + score: 0.2, + state: {}, + title: 'Suggestion1', + previewIcon: 'empty', + }, + { + score: 0.6, + state: suggestionVisState, + title: 'Suggestion2', + previewIcon: 'empty', + }, + ], + }, + testVis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + score: 0.8, + state: {}, + title: 'Suggestion3', + previewIcon: 'empty', + }, + ], + }, }, - }, - datasourceMap: { - testDatasource: { - ...mockDatasource, - getDatasourceSuggestionsForField: () => [generateSuggestion()], - getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], - getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], - DataPanelComponent: jest.fn().mockImplementation(() =>
), + datasourceMap: { + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsForField: () => [generateSuggestion()], + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], + getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], + DataPanelComponent: jest.fn().mockImplementation(() =>
), + }, }, - }, - - ExpressionRenderer: expressionRendererMock, - } as EditorFrameProps; - instance = ( - await mountWithProvider(, { - preloadedState: { - datasourceStates: { - testDatasource: { - isLoading: false, - state: { - internalState1: '', + } as EditorFrameProps; + instance = ( + await mountWithProvider(, { + preloadedState: { + datasourceStates: { + testDatasource: { + isLoading: false, + state: { + internalState1: '', + }, }, }, }, - }, - }) - ).instance; - - instance.update(); + }) + ).instance; + + instance.update(); + + act(() => { + instance.find('[data-test-subj="mockVisA"]').find(DragDrop).prop('onDrop')!( + { + indexPatternId: '1', + field: {}, + id: '1', + humanData: { label: 'draggedField' }, + }, + 'field_add' + ); + }); - act(() => { - instance.find('[data-test-subj="mockVisA"]').find(DragDrop).prop('onDrop')!( - { - indexPatternId: '1', - field: {}, - id: '1', - humanData: { label: 'draggedField' }, - }, - 'field_add' + expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + state: suggestionVisState, + }) ); }); - expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( - expect.objectContaining({ - state: suggestionVisState, - }) - ); - }); - - it('should use the highest priority suggestion available', async () => { - mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); - const suggestionVisState = {}; - const mockVisualization3 = { - ...createMockVisualization(), - id: 'testVis3', - getLayerIds: () => ['third'], - visualizationTypes: [ - { - icon: 'empty', - id: 'testVis3', - label: 'TEST3', - groupLabel: 'testVis3Group', - }, - ], - getSuggestions: () => [ - { - score: 0.9, - state: suggestionVisState, - title: 'Suggestion3', - previewIcon: 'empty', - }, - { - score: 0.7, - state: {}, - title: 'Suggestion4', - previewIcon: 'empty', - }, - ], - }; - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: { - ...mockVisualization, - // do not return suggestions for the currently active vis, otherwise it will be chosen - getSuggestions: () => [], - }, - testVis2: { - ...mockVisualization2, - getSuggestions: () => [], - }, - testVis3: { - ...mockVisualization3, + it('should use the highest priority suggestion available', async () => { + mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); + const suggestionVisState = {}; + const mockVisualization3 = { + ...createMockVisualization('testVis3', ['third']), + getSuggestions: () => [ + { + score: 0.9, + state: suggestionVisState, + title: 'Suggestion3', + previewIcon: 'empty', + }, + { + score: 0.7, + state: {}, + title: 'Suggestion4', + previewIcon: 'empty', + }, + ], + }; + + const props = { + ...getDefaultProps(), + visualizationMap: { + testVis: { + ...mockVisualization, + // do not return suggestions for the currently active vis, otherwise it will be chosen + getSuggestions: () => [], + }, + testVis2: { + ...mockVisualization2, + getSuggestions: () => [], + }, + testVis3: { + ...mockVisualization3, + }, }, - }, - datasourceMap: { - testDatasource: { - ...mockDatasource, - getDatasourceSuggestionsForField: () => [generateSuggestion()], - getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], - getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], - DataPanelComponent: jest.fn().mockImplementation(() => { - const [, dndDispatch] = useDragDropContext(); - useEffect(() => { - dndDispatch({ - type: 'startDragging', - payload: { - dragging: { - id: 'draggedField', - humanData: { label: '1' }, + datasourceMap: { + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsForField: () => [generateSuggestion()], + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], + getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], + DataPanelComponent: jest.fn().mockImplementation(() => { + const [, dndDispatch] = useDragDropContext(); + useEffect(() => { + dndDispatch({ + type: 'startDragging', + payload: { + dragging: { + id: 'draggedField', + humanData: { label: '1' }, + }, }, - }, - }); - }, [dndDispatch]); - return
; - }), + }); + }, [dndDispatch]); + return
; + }), + }, }, - }, - ExpressionRenderer: expressionRendererMock, - } as EditorFrameProps; + } as EditorFrameProps; - instance = (await mountWithProvider()).instance; + instance = (await mountWithProvider()).instance; - instance.update(); + instance.update(); - act(() => { - instance.find(DragDrop).filter('[dataTestSubj="lnsWorkspace"]').prop('onDrop')!( - { - indexPatternId: '1', - field: {}, - id: '1', - humanData: { - label: 'label', + act(() => { + instance.find(DragDrop).filter('[dataTestSubj="lnsWorkspace"]').prop('onDrop')!( + { + indexPatternId: '1', + field: {}, + id: '1', + humanData: { + label: 'label', + }, }, - }, - 'field_add' + 'field_add' + ); + }); + + expect(mockVisualization3.getConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + state: suggestionVisState, + }) ); }); - - expect(mockVisualization3.getConfiguration).toHaveBeenCalledWith( - expect.objectContaining({ - state: suggestionVisState, - }) - ); }); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx index 3e613d5a23e89..a82da848dc4a2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx @@ -6,62 +6,54 @@ */ import React from 'react'; -import { ReactWrapper } from 'enzyme'; -import type { PaletteOutput } from '@kbn/coloring'; +import { screen, fireEvent, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { createMockVisualization, mockStoreDeps, createMockFramePublicAPI, mockDatasourceMap, mockDatasourceStates, + renderWithReduxStore, } from '../../../mocks'; -import { mountWithProvider } from '../../../mocks'; - -// Tests are executed in a jsdom environment who does not have sizing methods, -// thus the AutoSizer will always compute a 0x0 size space -// Mock the AutoSizer inside EuiSelectable (Chart Switch) and return some dimensions > 0 -jest.mock('react-virtualized-auto-sizer', () => { - return function (props: { - children: (dimensions: { width: number; height: number }) => React.ReactNode; - }) { - const { children } = props; - return
{children({ width: 100, height: 100 })}
; - }; -}); -import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../../types'; -import { ChartSwitch } from './chart_switch'; -import { applyChanges } from '../../../state_management'; +import { + Visualization, + FramePublicAPI, + DatasourcePublicAPI, + SuggestionRequest, +} from '../../../types'; +import { ChartSwitch, ChartSwitchProps } from './chart_switch'; +import { LensAppState, applyChanges } from '../../../state_management'; describe('chart_switch', () => { function generateVisualization(id: string): jest.Mocked { return { - ...createMockVisualization(), - id, - getVisualizationTypeId: jest.fn((_state) => id), - visualizationTypes: [ + ...createMockVisualization(id), + getSuggestions: jest.fn((options) => [ { - icon: 'empty', - id, - label: `Label ${id}`, - groupLabel: `${id}Group`, + score: 1, + title: '', + state: `suggestion ${id}`, + previewIcon: 'empty', }, - ], - initialize: jest.fn((_addNewLayer, state) => { - return state || `${id} initial state`; - }), - getSuggestions: jest.fn((options) => { - return [ - { - score: 1, - title: '', - state: `suggestion ${id}`, - previewIcon: 'empty', - }, - ]; - }), + ]), }; } + let visualizationMap = mockVisualizationMap(); + let datasourceMap = mockDatasourceMap(); + let datasourceStates = mockDatasourceStates(); + let frame = mockFrame(['a']); + + beforeEach(() => { + visualizationMap = mockVisualizationMap(); + datasourceMap = mockDatasourceMap(); + datasourceStates = mockDatasourceStates(); + frame = mockFrame(['a']); + }); + afterEach(() => { + jest.clearAllMocks(); + }); /** * There are three visualizations. Each one has the same suggestion behavior: @@ -145,44 +137,66 @@ describe('chart_switch', () => { } as FramePublicAPI; } - function showFlyout(instance: ReactWrapper) { - instance.find('button[data-test-subj="lnsChartSwitchPopover"]').first().simulate('click'); - } - - function switchTo(subType: string, instance: ReactWrapper) { - showFlyout(instance); - instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first().simulate('click'); - } - - function getMenuItem(subType: string, instance: ReactWrapper) { - showFlyout(instance); - return instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first(); - } - it('should use suggested state if there is a suggestion from the target visualization', async () => { - const visualizationMap = mockVisualizationMap(); - const { instance, lensStore } = await mountWithProvider( + const renderChartSwitch = ( + propsOverrides: Partial = {}, + { preloadedStateOverrides }: { preloadedStateOverrides: Partial } = { + preloadedStateOverrides: {}, + } + ) => { + const { store, ...rtlRender } = renderWithReduxStore( , + {}, { + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), preloadedState: { visualization: { activeId: 'visA', state: 'state from a', }, + datasourceStates, + activeDatasourceId: 'testDatasource', + ...preloadedStateOverrides, }, } ); - switchTo('visB', instance); + const openChartSwitch = () => { + userEvent.click(screen.getByTestId('lnsChartSwitchPopover')); + }; + + const getMenuItem = (subType: string) => { + const list = screen.getByTestId('lnsChartSwitchList'); + return within(list).getByTestId(`lnsChartSwitchPopover_${subType}`); + }; + + const switchToVis = (subType: string) => { + fireEvent.click(getMenuItem(subType)); + }; + + return { + ...rtlRender, + store, + switchToVis, + getMenuItem, + openChartSwitch, + }; + }; + + it('should use suggested state if there is a suggestion from the target visualization', async () => { + const { store, openChartSwitch, switchToVis } = renderChartSwitch(); + openChartSwitch(); + switchToVis('visB'); - expect(lensStore.dispatch).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', payload: { suggestion: { - visualizationState: 'suggestion visB', + visualizationState: 'visB initial state', newVisualizationId: 'visB', datasourceId: 'testDatasource', datasourceState: {}, @@ -190,38 +204,18 @@ describe('chart_switch', () => { clearStagedPreview: true, }, }); - expect(lensStore.dispatch).not.toHaveBeenCalledWith({ type: applyChanges.type }); // should not apply changes automatically + expect(store.dispatch).not.toHaveBeenCalledWith({ type: applyChanges.type }); // should not apply changes automatically }); it('should use initial state if there is no suggestion from the target visualization', async () => { - const visualizationMap = mockVisualizationMap(); visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); - const frame = mockFrame(['a']); (frame.datasourceLayers.a?.getTableSpec as jest.Mock).mockReturnValue([]); - const datasourceMap = mockDatasourceMap(); - const datasourceStates = mockDatasourceStates(); - const { instance, lensStore } = await mountWithProvider( - , - { - storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), - preloadedState: { - datasourceStates, - activeDatasourceId: 'testDatasource', - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); + const { store, switchToVis, openChartSwitch } = renderChartSwitch(); + openChartSwitch(); + switchToVis('visB'); - switchTo('visB', instance); expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'a'); // from preloaded state - expect(lensStore.dispatch).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', payload: { suggestion: { @@ -231,16 +225,13 @@ describe('chart_switch', () => { clearStagedPreview: true, }, }); - expect(lensStore.dispatch).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/removeLayers', payload: { layerIds: ['a'], visualizationId: 'visA' }, }); }); it('should indicate data loss if not all columns will be used', async () => { - const visualizationMap = mockVisualizationMap(); - const frame = mockFrame(['a']); - const datasourceMap = mockDatasourceMap(); datasourceMap.testDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { state: {}, @@ -276,61 +267,21 @@ describe('chart_switch', () => { { columnId: 'col3', fields: [] }, ]); - const { instance } = await mountWithProvider( - , - { - preloadedState: { - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); + const { openChartSwitch, getMenuItem } = renderChartSwitch(); + openChartSwitch(); - expect( - getMenuItem('visB', instance) - .find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') - .first() - .props().type - ).toEqual('warning'); + expect(within(getMenuItem('visB')).getByText(/warning/i)).toBeInTheDocument(); }); it('should indicate data loss if not all layers will be used', async () => { - const visualizationMap = mockVisualizationMap(); - const frame = mockFrame(['a', 'b']); - const { instance } = await mountWithProvider( - , - { - preloadedState: { - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); - - expect( - getMenuItem('visB', instance) - .find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') - .first() - .props().type - ).toEqual('warning'); + frame = mockFrame(['a', 'b']); + const { openChartSwitch, getMenuItem } = renderChartSwitch(); + openChartSwitch(); + expect(within(getMenuItem('visB')).getByText(/warning/i)).toBeInTheDocument(); }); it('should support multi-layer suggestions without data loss', async () => { - const visualizationMap = mockVisualizationMap(); - const frame = mockFrame(['a', 'b']); - const datasourceMap = mockDatasourceMap(); + frame = mockFrame(['a', 'b']); datasourceMap.testDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { state: {}, @@ -352,147 +303,53 @@ describe('chart_switch', () => { keptLayerIds: ['a', 'b'], }, ]); - - const { instance } = await mountWithProvider( - , - { - preloadedState: { - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); - - expect( - getMenuItem('visB', instance).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') - ).toHaveLength(0); + const { openChartSwitch, getMenuItem } = renderChartSwitch(); + openChartSwitch(); + expect(within(getMenuItem('visB')).queryByText(/warning/i)).not.toBeInTheDocument(); }); it('should indicate data loss if no data will be used', async () => { - const visualizationMap = mockVisualizationMap(); visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); - const frame = mockFrame(['a']); - - const { instance } = await mountWithProvider( - , - { - preloadedState: { - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); - - expect( - getMenuItem('visB', instance) - .find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') - .first() - .props().type - ).toEqual('warning'); + const { openChartSwitch, getMenuItem } = renderChartSwitch(); + openChartSwitch(); + expect(within(getMenuItem('visB')).queryByText(/warning/i)).toBeInTheDocument(); }); it('should not indicate data loss if there is no data', async () => { - const visualizationMap = mockVisualizationMap(); visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); - const frame = mockFrame(['a']); + frame = mockFrame(['a']); (frame.datasourceLayers.a?.getTableSpec as jest.Mock).mockReturnValue([]); - - const { instance } = await mountWithProvider( - , - - { - preloadedState: { - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); - - expect( - getMenuItem('visB', instance).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') - ).toHaveLength(0); + const { openChartSwitch, getMenuItem } = renderChartSwitch(); + openChartSwitch(); + expect(within(getMenuItem('visB')).queryByText(/warning/i)).not.toBeInTheDocument(); }); it('should not show a warning when the subvisualization is the same', async () => { - const frame = mockFrame(['a', 'b', 'c']); - const visualizationMap = mockVisualizationMap(); - visualizationMap.visC.getVisualizationTypeId.mockReturnValue('subvisC2'); - const switchVisualizationType = jest.fn(() => ({ type: 'subvisC1' })); + frame = mockFrame(['a', 'b', 'c']); - visualizationMap.visC.switchVisualizationType = switchVisualizationType; - - const datasourceStates = mockDatasourceStates(); - - const { instance } = await mountWithProvider( - , - { - preloadedState: { - datasourceStates, - activeDatasourceId: 'testDatasource', - visualization: { - activeId: 'visC', - state: { type: 'subvisC2' }, - }, + visualizationMap.visC.getVisualizationTypeId.mockReturnValue('subvisC2'); + visualizationMap.visC.switchVisualizationType = jest.fn(() => ({ type: 'subvisC1' })); + const { openChartSwitch, getMenuItem } = renderChartSwitch(undefined, { + preloadedStateOverrides: { + visualization: { + activeId: 'visC', + state: { type: 'subvisC2' }, }, - } - ); - - expect( - getMenuItem('subvisC2', instance).find( - '[data-test-subj="lnsChartSwitchPopoverAlert_subvisC2"]' - ) - ).toHaveLength(0); + }, + }); + openChartSwitch(); + expect(within(getMenuItem('subvisC2')).queryByText(/warning/i)).not.toBeInTheDocument(); }); it('should get suggestions when switching subvisualization', async () => { - const visualizationMap = mockVisualizationMap(); visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); - const frame = mockFrame(['a', 'b', 'c']); - const datasourceMap = mockDatasourceMap(); + frame = mockFrame(['a', 'b', 'c']); datasourceMap.testDatasource.getLayers.mockReturnValue(['a', 'b', 'c']); - const datasourceStates = mockDatasourceStates(); - const { instance, lensStore } = await mountWithProvider( - , - { - storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), - preloadedState: { - datasourceStates, - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); + const { openChartSwitch, switchToVis, store } = renderChartSwitch(); + openChartSwitch(); + switchToVis('visB'); - switchTo('visB', instance); expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'a'); expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'b'); expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'c'); @@ -502,7 +359,7 @@ describe('chart_switch', () => { }) ); - expect(lensStore.dispatch).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', payload: { suggestion: { @@ -517,75 +374,49 @@ describe('chart_switch', () => { }); it('should query main palette from active chart and pass into suggestions', async () => { - const visualizationMap = mockVisualizationMap(); - const mockPalette: PaletteOutput = { type: 'palette', name: 'mock' }; - visualizationMap.visA.getMainPalette = jest.fn(() => ({ + const legacyPalette: SuggestionRequest['mainPalette'] = { type: 'legacyPalette', - value: mockPalette, - })); + value: { type: 'palette', name: 'mock' }, + }; + visualizationMap.visA.getMainPalette = jest.fn(() => legacyPalette); visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); - const frame = mockFrame(['a', 'b', 'c']); - const currentVisState = {}; - const datasourceMap = mockDatasourceMap(); + frame = mockFrame(['a', 'b', 'c']); datasourceMap.testDatasource.getLayers.mockReturnValue(['a', 'b', 'c']); - const { instance } = await mountWithProvider( - , - { - preloadedState: { - visualization: { - activeId: 'visA', - state: currentVisState, - }, - }, - storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), - } - ); - - switchTo('visB', instance); + const { openChartSwitch, switchToVis } = renderChartSwitch(); + openChartSwitch(); + switchToVis('visB'); - expect(visualizationMap.visA.getMainPalette).toHaveBeenCalledWith(currentVisState); + expect(visualizationMap.visA.getMainPalette).toHaveBeenCalledWith('state from a'); expect(visualizationMap.visB.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ keptLayerIds: ['a'], - mainPalette: { type: 'legacyPalette', value: mockPalette }, + mainPalette: legacyPalette, }) ); }); it('should not remove layers when switching between subtypes', async () => { - const frame = mockFrame(['a', 'b', 'c']); - const visualizationMap = mockVisualizationMap(); - const switchVisualizationType = jest.fn(() => 'switched'); + frame = mockFrame(['a', 'b', 'c']); + visualizationMap.visC.switchVisualizationType = jest.fn(() => 'switched'); - visualizationMap.visC.switchVisualizationType = switchVisualizationType; - const datasourceMap = mockDatasourceMap(); - const { instance, lensStore } = await mountWithProvider( - , - { - preloadedState: { - visualization: { - activeId: 'visC', - state: { type: 'subvisC1' }, - }, + const { openChartSwitch, switchToVis, store } = renderChartSwitch(undefined, { + preloadedStateOverrides: { + visualization: { + activeId: 'visC', + state: { type: 'subvisC1' }, }, - storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), - } - ); + }, + }); - switchTo('subvisC3', instance); - expect(switchVisualizationType).toHaveBeenCalledWith('subvisC3', { type: 'subvisC3' }); + openChartSwitch(); + switchToVis('subvisC3'); + expect(visualizationMap.visC.switchVisualizationType).toHaveBeenCalledWith('subvisC3', { + type: 'subvisC3', + }); - expect(lensStore.dispatch).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', payload: { suggestion: { @@ -601,31 +432,22 @@ describe('chart_switch', () => { }); it('should not remove layers and initialize with existing state when switching between subtypes without data', async () => { - const frame = mockFrame(['a']); const datasourceLayers = frame.datasourceLayers as Record; datasourceLayers.a.getTableSpec = jest.fn().mockReturnValue([]); - const visualizationMap = mockVisualizationMap(); + visualizationMap.visC.getSuggestions = jest.fn().mockReturnValue([]); visualizationMap.visC.switchVisualizationType = jest.fn(() => 'switched'); - const datasourceMap = mockDatasourceMap(); - const { instance } = await mountWithProvider( - , - { - preloadedState: { - visualization: { - activeId: 'visC', - state: { type: 'subvisC1' }, - }, - }, - storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), - } - ); - switchTo('subvisC3', instance); + const { openChartSwitch, switchToVis } = renderChartSwitch(undefined, { + preloadedStateOverrides: { + visualization: { + activeId: 'visC', + state: { type: 'subvisC1' }, + }, + }, + }); + openChartSwitch(); + switchToVis('subvisC3'); expect(visualizationMap.visC.switchVisualizationType).toHaveBeenCalledWith('subvisC3', { type: 'subvisC1', @@ -634,9 +456,8 @@ describe('chart_switch', () => { }); it('should switch to the updated datasource state', async () => { - const visualizationMap = mockVisualizationMap(); - const frame = mockFrame(['a', 'b']); - const datasourceMap = mockDatasourceMap(); + frame = mockFrame(['a', 'b']); + datasourceMap.testDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { state: 'testDatasource suggestion', @@ -666,33 +487,19 @@ describe('chart_switch', () => { keptLayerIds: [], }, ]); - const { instance, lensStore } = await mountWithProvider( - , - { - storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), - preloadedState: { - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); - switchTo('visB', instance); + const { openChartSwitch, switchToVis, store } = renderChartSwitch(); + openChartSwitch(); + switchToVis('visB'); - expect(lensStore.dispatch).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', payload: { suggestion: { newVisualizationId: 'visB', datasourceId: 'testDatasource', datasourceState: 'testDatasource suggestion', - visualizationState: 'suggestion visB', + visualizationState: 'visB initial state', }, clearStagedPreview: true, }, @@ -700,37 +507,18 @@ describe('chart_switch', () => { }); it('should ensure the new visualization has the proper subtype', async () => { - const visualizationMap = mockVisualizationMap(); - const switchVisualizationType = jest.fn( + visualizationMap.visB.switchVisualizationType = jest.fn( (visualizationType, state) => `${state} ${visualizationType}` ); + const { openChartSwitch, switchToVis, store } = renderChartSwitch(); + openChartSwitch(); + switchToVis('visB'); - visualizationMap.visB.switchVisualizationType = switchVisualizationType; - const datasourceMap = mockDatasourceMap(); - const { instance, lensStore } = await mountWithProvider( - , - { - storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), - preloadedState: { - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); - - switchTo('visB', instance); - - expect(lensStore.dispatch).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith({ type: 'lens/switchVisualization', payload: { suggestion: { - visualizationState: 'suggestion visB visB', + visualizationState: 'visB initial state visB', newVisualizationId: 'visB', datasourceId: 'testDatasource', datasourceState: {}, @@ -741,56 +529,27 @@ describe('chart_switch', () => { }); it('should use the suggestion that matches the subtype', async () => { - const visualizationMap = mockVisualizationMap(); - const switchVisualizationType = jest.fn(); - - visualizationMap.visC.switchVisualizationType = switchVisualizationType; - const datasourceMap = mockDatasourceMap(); - const { instance } = await mountWithProvider( - , - { - preloadedState: { - visualization: { - activeId: 'visC', - state: { type: 'subvisC3' }, - }, + const { openChartSwitch, switchToVis } = renderChartSwitch(undefined, { + preloadedStateOverrides: { + visualization: { + activeId: 'visC', + state: { type: 'subvisC3' }, }, - } - ); - - switchTo('subvisC1', instance); - expect(switchVisualizationType).toHaveBeenCalledWith('subvisC1', { + }, + }); + openChartSwitch(); + switchToVis('subvisC1'); + expect(visualizationMap.visC.switchVisualizationType).toHaveBeenCalledWith('subvisC1', { type: 'subvisC1', notPrimary: true, }); }); it('should show all visualization types', async () => { - const datasourceMap = mockDatasourceMap(); - const { instance } = await mountWithProvider( - , - { - preloadedState: { - visualization: { - activeId: 'visA', - state: {}, - }, - }, - } - ); - - showFlyout(instance); - - const allDisplayed = ['visA', 'visB', 'subvisC1', 'subvisC2', 'subvisC3'].every( - (subType) => instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0 + const { openChartSwitch, getMenuItem } = renderChartSwitch(); + openChartSwitch(); + const allDisplayed = ['visA', 'visB', 'subvisC1', 'subvisC2', 'subvisC3'].every((subType) => + getMenuItem(subType) ); expect(allDisplayed).toBeTruthy(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index c40c81285dc15..2cb8fc75a6758 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -55,7 +55,7 @@ interface VisualizationSelection { sameDatasources?: boolean; } -interface Props { +export interface ChartSwitchProps { framePublicAPI: FramePublicAPI; visualizationMap: VisualizationMap; datasourceMap: DatasourceMap; @@ -126,7 +126,7 @@ function getCurrentVisualizationId( ); } -export const ChartSwitch = memo(function ChartSwitch(props: Props) { +export const ChartSwitch = memo(function ChartSwitch(props: ChartSwitchProps) { const [flyoutOpen, setFlyoutOpen] = useState(false); const dispatchLens = useLensDispatch(); const activeDatasourceId = useLensSelector(selectActiveDatasourceId); @@ -494,7 +494,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { }); function getTopSuggestion( - props: Props, + props: ChartSwitchProps, visualizationId: string, datasourceStates: DatasourceStates, visualization: VisualizationState, diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.tsx b/x-pack/plugins/lens/public/mocks/datasource_mock.tsx index 207c6562be557..daf56218bcd07 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.tsx +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.tsx @@ -51,7 +51,7 @@ export function createMockDatasource( removeLayer: jest.fn((state, layerId) => ({ newState: state, removedLayerIds: [layerId] })), cloneLayer: jest.fn((_state, _layerId, _newLayerId, getNewId) => {}), removeColumn: jest.fn((props) => {}), - getLayers: jest.fn((_state) => []), + getLayers: jest.fn((_state) => ['a']), uniqueLabels: jest.fn((_state, dataViews) => ({})), getDropProps: jest.fn(), onDrop: jest.fn(), diff --git a/x-pack/plugins/lens/public/mocks/expression_renderer_mock.tsx b/x-pack/plugins/lens/public/mocks/expression_renderer_mock.tsx index 42187662e0213..842d6ccbb713f 100644 --- a/x-pack/plugins/lens/public/mocks/expression_renderer_mock.tsx +++ b/x-pack/plugins/lens/public/mocks/expression_renderer_mock.tsx @@ -12,5 +12,7 @@ export function createExpressionRendererMock(): jest.Mock< React.ReactElement, [ReactExpressionRendererProps] > { - return jest.fn(({ expression }) => {expression || 'Expression renderer mock'}); + return jest.fn(({ expression }) => ( + {expression || 'Expression renderer mock'} + )); } diff --git a/x-pack/plugins/lens/public/mocks/store_mocks.tsx b/x-pack/plugins/lens/public/mocks/store_mocks.tsx index 0d78a07d3dfce..ad028bfe107f0 100644 --- a/x-pack/plugins/lens/public/mocks/store_mocks.tsx +++ b/x-pack/plugins/lens/public/mocks/store_mocks.tsx @@ -69,7 +69,7 @@ export const renderWithReduxStore = ( { preloadedState, storeDeps, - }: { preloadedState: Partial; storeDeps?: LensStoreDeps } = { + }: { preloadedState?: Partial; storeDeps?: LensStoreDeps } = { preloadedState: {}, storeDeps: mockStoreDeps(), } diff --git a/x-pack/plugins/lens/public/mocks/visualization_mock.tsx b/x-pack/plugins/lens/public/mocks/visualization_mock.tsx index b368f994756eb..663345c824095 100644 --- a/x-pack/plugins/lens/public/mocks/visualization_mock.tsx +++ b/x-pack/plugins/lens/public/mocks/visualization_mock.tsx @@ -6,39 +6,42 @@ */ import React from 'react'; import { LayerTypes } from '@kbn/expression-xy-plugin/public'; +import { toExpression } from '@kbn/interpreter'; +import faker from 'faker'; import { Visualization, VisualizationMap } from '../types'; -export function createMockVisualization(id = 'testVis'): jest.Mocked { - const layerId = 'layer1'; - +export function createMockVisualization( + id = 'testVis', + layerIds = ['layer1'] +): jest.Mocked { return { id, clearLayer: jest.fn((state, _layerId, _indexPatternId) => state), removeLayer: jest.fn(), - getLayerIds: jest.fn((_state) => [layerId]), + getLayerIds: jest.fn((_state) => layerIds), getSupportedLayers: jest.fn(() => [{ type: LayerTypes.DATA, label: 'Data Layer' }]), getLayerType: jest.fn((_state, _layerId) => LayerTypes.DATA), visualizationTypes: [ { icon: 'empty', id, - label: 'TEST', + label: faker.lorem.word(), groupLabel: `${id}Group`, }, ], appendLayer: jest.fn(), - getVisualizationTypeId: jest.fn((_state) => 'empty'), + getVisualizationTypeId: jest.fn((_state) => id), getDescription: jest.fn((_state) => ({ label: '' })), switchVisualizationType: jest.fn((_, x) => x), getSuggestions: jest.fn((_options) => []), getRenderEventCounters: jest.fn((_state) => []), - initialize: jest.fn((_addNewLayer, _state) => ({ newState: 'newState' })), + initialize: jest.fn((_addNewLayer, _state) => `${id} initial state`), getConfiguration: jest.fn((props) => ({ groups: [ { groupId: 'a', groupLabel: 'a', - layerId, + layerId: layerIds[0], supportsMoreColumns: true, accessors: [], filterOperations: jest.fn(() => true), @@ -46,7 +49,15 @@ export function createMockVisualization(id = 'testVis'): jest.Mocked 'expression'), + toExpression: jest.fn((state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpression({ + type: 'expression', + chain: [ + ...(datasourceExpressionsByLayers.first?.chain ?? []), + { type: 'function', function: 'testVis', arguments: {} }, + ], + }) + ), toPreviewExpression: jest.fn((_state, _frame) => 'expression'), getUserMessages: jest.fn((_state) => []), setDimension: jest.fn(), diff --git a/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap b/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap index 4c09cefdd6de9..68d4e7d57e983 100644 --- a/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap +++ b/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap @@ -100,9 +100,7 @@ Object { }, "visualization": Object { "activeId": "testVis", - "state": Object { - "newState": "newState", - }, + "state": "testVis initial state", }, }, } diff --git a/x-pack/plugins/lens/public/state_management/load_initial.test.tsx b/x-pack/plugins/lens/public/state_management/load_initial.test.tsx index ef9d84062a720..130e1294b9c95 100644 --- a/x-pack/plugins/lens/public/state_management/load_initial.test.tsx +++ b/x-pack/plugins/lens/public/state_management/load_initial.test.tsx @@ -199,7 +199,7 @@ describe('Initializing the store', () => { expect(store.getState()).toEqual({ lens: expect.objectContaining({ visualization: { - state: { newState: 'newState' }, // new vis gets initialized + state: 'testVis initial state', // new vis gets initialized activeId: 'testVis', }, activeDatasourceId: 'testDatasource2', // resets to first on the list From 7f5486e1e6f4841d146a624ced3d5f2466ca6237 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 15 Feb 2024 13:21:37 -0500 Subject: [PATCH 17/23] [Response Ops][Task Manager ] Adding ability for ad-hoc task instance to specify timeout override (#175731) Resolves https://github.com/elastic/kibana/issues/174353 ## Summary Adds ability for task instance to specify a timeout override that will be used in place of the task type timeout when running an ad-hoc task. In the future we may consider allowing timeout overrides for recurring tasks but this PR limits usage to only ad-hoc task runs. This timeout override is planned for use by backfill rule execution tasks so the only usages in this PR are in the functional tests. --- x-pack/plugins/task_manager/server/task.ts | 5 + .../server/task_running/task_runner.test.ts | 85 ++++++++++++- .../server/task_running/task_runner.ts | 25 ++-- .../server/task_validator.test.ts | 113 ++++++++++++++++++ .../task_manager/server/task_validator.ts | 51 ++++++-- .../sample_task_plugin/server/init_routes.ts | 1 + .../sample_task_plugin/server/plugin.ts | 31 +++++ .../check_registered_task_types.ts | 1 + .../task_manager/task_management.ts | 37 ++++++ 9 files changed, 333 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts index b7b86c50c8b08..728d175e8b98f 100644 --- a/x-pack/plugins/task_manager/server/task.ts +++ b/x-pack/plugins/task_manager/server/task.ts @@ -345,6 +345,11 @@ export interface TaskInstance { * Indicates the number of skipped executions. */ numSkippedRuns?: number; + + /* + * Optionally override the timeout defined in the task type for this specific task instance + */ + timeoutOverride?: string; } /** diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index 6735b3c0602b8..1e22e15a7f1e4 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -250,7 +250,7 @@ describe('TaskManagerRunner', () => { expect(instance.enabled).not.toBeDefined(); }); - test('calculates retryAt by timeout if it exceeds the schedule when running a recurring task', async () => { + test('calculates retryAt by task type timeout if it exceeds the schedule when running a recurring task', async () => { const timeoutMinutes = 1; const intervalSeconds = 20; const id = _.random(1, 20).toString(); @@ -286,6 +286,44 @@ describe('TaskManagerRunner', () => { expect(instance.enabled).not.toBeDefined(); }); + test('does not calculate retryAt by task instance timeout if defined for a recurring task', async () => { + const timeoutMinutes = 1; + const timeoutOverrideSeconds = 90; + const intervalSeconds = 20; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalSeconds}s`, + }, + timeoutOverride: `${timeoutOverrideSeconds}s`, + enabled: true, + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); + + await runner.markTaskAsRunning(); + + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; + + expect(instance.retryAt!.getTime()).toEqual( + instance.startedAt!.getTime() + timeoutMinutes * 60 * 1000 + ); + expect(instance.enabled).not.toBeDefined(); + }); + test('sets startedAt, status, attempts and retryAt when claiming a task', async () => { const timeoutMinutes = 1; const id = _.random(1, 20).toString(); @@ -329,6 +367,51 @@ describe('TaskManagerRunner', () => { expect(instance.enabled).not.toBeDefined(); }); + test('test sets retryAt to task instance timeout override when defined when claiming an ad hoc task', async () => { + const timeoutSeconds = 60; + const timeoutOverrideSeconds = 90; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + enabled: true, + attempts: initialAttempts, + timeoutOverride: `${timeoutOverrideSeconds}s`, + schedule: undefined, + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutSeconds}s`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); + + await runner.markTaskAsRunning(); + + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; + + expect(instance.attempts).toEqual(initialAttempts + 1); + expect(instance.status).toBe('running'); + expect(instance.startedAt!.getTime()).toEqual(Date.now()); + + const minRunAt = Date.now(); + const maxRunAt = minRunAt + baseDelay * Math.pow(2, initialAttempts - 1); + expect(instance.retryAt!.getTime()).toBeGreaterThanOrEqual( + minRunAt + timeoutOverrideSeconds * 1000 + ); + expect(instance.retryAt!.getTime()).toBeLessThanOrEqual( + maxRunAt + timeoutOverrideSeconds * 1000 + ); + + expect(instance.enabled).not.toBeDefined(); + }); + test('sets retryAt when there is an error', async () => { const id = _.random(1, 20).toString(); const initialAttempts = _.random(1, 3); diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index faea2bfb7e446..2c1242629952c 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -260,10 +260,25 @@ export class TaskManagerRunner implements TaskRunner { // this allows us to catch tasks that remain in Pending/Finalizing without being // cleaned up isReadyToRun(this.instance) ? this.instance.task.startedAt : this.instance.timestamp, - this.definition.timeout + this.timeout )!; } + /* + * Gets the timeout of the current task. Uses the timeout + * defined by the task type unless this is an ad-hoc task that specifies an override + */ + public get timeout() { + if (this.instance.task.schedule) { + // recurring tasks should use timeout in task type + return this.definition.timeout; + } + + return this.instance.task.timeoutOverride + ? this.instance.task.timeoutOverride + : this.definition.timeout; + } + /** * Gets the duration of the current task run */ @@ -521,17 +536,13 @@ export class TaskManagerRunner implements TaskRunner { attempts, retryAt: (this.instance.task.schedule - ? maxIntervalFromDate( - now, - this.instance.task.schedule.interval, - this.definition.timeout - ) + ? maxIntervalFromDate(now, this.instance.task.schedule.interval, this.timeout) : this.getRetryDelay({ attempts, // Fake an error. This allows retry logic when tasks keep timing out // and lets us set a proper "retryAt" value each time. error: new Error('Task timeout'), - addDuration: this.definition.timeout, + addDuration: this.timeout, })) ?? null, // This is a safe conversion as we're setting the startAt above }, diff --git a/x-pack/plugins/task_manager/server/task_validator.test.ts b/x-pack/plugins/task_manager/server/task_validator.test.ts index 52822adf6f49f..01eb7097d15c2 100644 --- a/x-pack/plugins/task_manager/server/task_validator.test.ts +++ b/x-pack/plugins/task_manager/server/task_validator.test.ts @@ -322,6 +322,49 @@ describe('TaskValidator', () => { expect(result).toEqual(task); }); + it(`should return the task with timeoutOverride as-is whenever schedule is not defined and override is valid`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + const taskValidator = new TaskValidator({ + logger: mockLogger(), + definitions, + allowReadingInvalidState: false, + }); + const task = taskManagerMock.createTask({ timeoutOverride: '1s' }); + const result = taskValidator.getValidatedTaskInstanceForUpdating(task); + expect(result).toEqual(task); + }); + + it(`should return the task with timeoutOverride stripped whenever schedule and override are defined`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + const taskValidator = new TaskValidator({ + logger: mockLogger(), + definitions, + allowReadingInvalidState: false, + }); + const task = taskManagerMock.createTask({ + schedule: { interval: '1d' }, + timeoutOverride: '1s', + }); + const { timeoutOverride, ...taskWithoutOverride } = task; + const result = taskValidator.getValidatedTaskInstanceForUpdating(task); + expect(result).toEqual(taskWithoutOverride); + }); + + it(`should return the task with timeoutOverride stripped whenever override is invalid`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + const taskValidator = new TaskValidator({ + logger: mockLogger(), + definitions, + allowReadingInvalidState: false, + }); + const task = taskManagerMock.createTask({ + timeoutOverride: 'foo', + }); + const { timeoutOverride, ...taskWithoutOverride } = task; + const result = taskValidator.getValidatedTaskInstanceForUpdating(task); + expect(result).toEqual(taskWithoutOverride); + }); + // TODO: Remove skip once all task types have defined their state schema. // https://github.com/elastic/kibana/issues/159347 it.skip(`should fail to validate the state schema when the task type doesn't have stateSchemaByVersion defined`, () => { @@ -394,4 +437,74 @@ describe('TaskValidator', () => { ).toThrowErrorMatchingInlineSnapshot(`"[bar]: definition for this key is missing"`); }); }); + + describe('validateTimeoutOverride()', () => { + it(`should validate when specifying a valid timeout override field with no schedule`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + const taskValidator = new TaskValidator({ + logger: mockLogger(), + definitions, + allowReadingInvalidState: false, + }); + const task = taskManagerMock.createTask({ + timeoutOverride: '1s', + state: { foo: 'foo' }, + }); + expect(taskValidator.validateTimeoutOverride(task)).toEqual(task); + }); + + it(`should validate when specifying a schedule and no timeout override`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + const taskValidator = new TaskValidator({ + logger: mockLogger(), + definitions, + allowReadingInvalidState: false, + }); + const task = taskManagerMock.createTask({ + schedule: { interval: '1d' }, + state: { foo: 'foo' }, + }); + expect(taskValidator.validateTimeoutOverride(task)).toEqual(task); + }); + + it(`should fail to validate when specifying a valid timeout override field and recurring schedule`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + const logger = mockLogger(); + const taskValidator = new TaskValidator({ + logger, + definitions, + allowReadingInvalidState: false, + }); + const task = taskManagerMock.createTask({ + timeoutOverride: '1s', + schedule: { interval: '1d' }, + state: { foo: 'foo' }, + }); + + const { timeoutOverride, ...taskWithoutOverride } = task; + expect(taskValidator.validateTimeoutOverride(task)).toEqual(taskWithoutOverride); + expect(logger.warn).toHaveBeenCalledWith( + `[TaskValidator] cannot specify timeout override 1s when scheduling a recurring task` + ); + }); + + it(`should fail to validate when specifying an invalid timeout override field`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + const logger = mockLogger(); + const taskValidator = new TaskValidator({ + logger, + definitions, + allowReadingInvalidState: false, + }); + const task = taskManagerMock.createTask({ + timeoutOverride: 'foo', + state: { foo: 'foo' }, + }); + const { timeoutOverride, ...taskWithoutOverride } = task; + expect(taskValidator.validateTimeoutOverride(task)).toEqual(taskWithoutOverride); + expect(logger.warn).toHaveBeenCalledWith( + `[TaskValidator] Invalid timeout override "foo". Timeout must be of the form "{number}{cadence}" where number is an integer. Example: 5m. This timeout override will be ignored.` + ); + }); + }); }); diff --git a/x-pack/plugins/task_manager/server/task_validator.ts b/x-pack/plugins/task_manager/server/task_validator.ts index 61d9a903dd5b4..ddc7304e4e31e 100644 --- a/x-pack/plugins/task_manager/server/task_validator.ts +++ b/x-pack/plugins/task_manager/server/task_validator.ts @@ -5,11 +5,13 @@ * 2.0. */ -import { max, memoize } from 'lodash'; +import { max, memoize, omit } from 'lodash'; import type { Logger } from '@kbn/core/server'; import type { ObjectType } from '@kbn/config-schema'; import { TaskTypeDictionary } from './task_type_dictionary'; import type { TaskInstance, ConcreteTaskInstance, TaskDefinition } from './task'; +import { isInterval, parseIntervalAsMillisecond } from './lib/intervals'; +import { isErr, tryAsResult } from './lib/result_type'; interface TaskValidatorOpts { allowReadingInvalidState: boolean; @@ -98,34 +100,67 @@ export class TaskValidator { task: T, options: { validate: boolean } = { validate: true } ): T { + const taskWithValidatedTimeout = this.validateTimeoutOverride(task); + if (!options.validate) { - return task; + return taskWithValidatedTimeout; } // In the scenario the task is unused / deprecated and Kibana needs to manipulate the task, // we'll do a pass-through for those - if (!this.definitions.has(task.taskType)) { - return task; + if (!this.definitions.has(taskWithValidatedTimeout.taskType)) { + return taskWithValidatedTimeout; } - const taskTypeDef = this.definitions.get(task.taskType); + const taskTypeDef = this.definitions.get(taskWithValidatedTimeout.taskType); const latestStateSchema = this.cachedGetLatestStateSchema(taskTypeDef); // TODO: Remove once all task types have defined their state schema. // https://github.com/elastic/kibana/issues/159347 // Otherwise, failures on read / write would occur. (don't forget to unskip test) if (!latestStateSchema) { - return task; + return taskWithValidatedTimeout; } // We are doing a write operation which must validate against the latest state schema return { - ...task, - state: this.getValidatedStateSchema(task.state, task.taskType, latestStateSchema, 'forbid'), + ...taskWithValidatedTimeout, + state: this.getValidatedStateSchema( + taskWithValidatedTimeout.state, + taskWithValidatedTimeout.taskType, + latestStateSchema, + 'forbid' + ), stateVersion: latestStateSchema?.version, }; } + public validateTimeoutOverride(task: T): T { + if (task.timeoutOverride) { + if ( + !isInterval(task.timeoutOverride) || + isErr(tryAsResult(() => parseIntervalAsMillisecond(task.timeoutOverride!))) + ) { + this.logger.warn( + `[TaskValidator] Invalid timeout override "${task.timeoutOverride}". Timeout must be of the form "{number}{cadence}" where number is an integer. Example: 5m. This timeout override will be ignored.` + ); + + return omit(task, 'timeoutOverride') as T; + } + } + + // Only allow timeoutOverride if schedule is not defined + if (!!task.timeoutOverride && !!task.schedule) { + this.logger.warn( + `[TaskValidator] cannot specify timeout override ${task.timeoutOverride} when scheduling a recurring task` + ); + + return omit(task, 'timeoutOverride') as T; + } + + return task; + } + private migrateTaskState( state: ConcreteTaskInstance['state'], currentVersion: number | undefined, diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts index 01f8cd6ba4bc9..b1c8d6dd028b2 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts @@ -62,6 +62,7 @@ export function initRoutes( params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), state: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), id: schema.maybe(schema.string()), + timeoutOverride: schema.maybe(schema.string()), }), }), }, diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts index 0e3a2bb993fe7..52c747d0ec786 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts @@ -167,6 +167,37 @@ export class SampleTaskManagerFixturePlugin }, }), }, + sampleAdHocTaskTimingOut: { + title: 'Sample Ad-Hoc Task that Times Out', + description: 'A sample task that times out.', + maxAttempts: 3, + timeout: '1s', + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + let isCancelled: boolean = false; + return { + async run() { + // wait for 15 seconds + await new Promise((r) => setTimeout(r, 15000)); + + if (!isCancelled) { + const [{ elasticsearch }] = await core.getStartServices(); + await elasticsearch.client.asInternalUser.index({ + index: '.kibana_task_manager_test_result', + body: { + type: 'task', + taskType: 'sampleAdHocTaskTimingOut', + taskId: taskInstance.id, + }, + refresh: true, + }); + } + }, + async cancel() { + isCancelled = true; + }, + }; + }, + }, sampleRecurringTaskWhichHangs: { title: 'Sample Recurring Task that Hangs for a minute', description: 'A sample task that Hangs for a minute on each run.', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 3b71c99df2e5b..e5d5e8fbd6fb7 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -20,6 +20,7 @@ export default function ({ getService }: FtrProviderContext) { } const TEST_TYPES = [ + 'sampleAdHocTaskTimingOut', 'lowPriorityTask', 'sampleOneTimeTaskThrowingError', 'sampleRecurringTaskTimingOut', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts index f897e0fa05035..a430993933c8b 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts @@ -791,6 +791,43 @@ export default function ({ getService }: FtrProviderContext) { }); }); + it('should fail to schedule recurring task with timeout override', async () => { + const task = await scheduleTask({ + taskType: 'sampleRecurringTaskTimingOut', + schedule: { interval: '1s' }, + timeoutOverride: '30s', + params: {}, + }); + + expect(task.timeoutOverride).to.be(undefined); + }); + + it('should allow timeout override for ad hoc tasks', async () => { + const task = await scheduleTask({ + taskType: 'sampleAdHocTaskTimingOut', + timeoutOverride: '30s', + params: {}, + }); + + expect(task.timeoutOverride).to.be('30s'); + + // this task type is set to time out after 1s but the task runner + // will wait 15 seconds and then index a document if it hasn't timed out + // this test overrides the timeout to 30s and checks if the expected + // document was indexed. presence of indexed document means the task + // timeout override was respected + await retry.try(async () => { + const [scheduledTask] = (await currentTasks()).docs; + expect(scheduledTask?.id).to.eql(task.id); + }); + + await retry.try(async () => { + const docs: RawDoc[] = await historyDocs(task.id); + expect(docs.length).to.eql(1); + expect(docs[0]._source.taskType).to.eql('sampleAdHocTaskTimingOut'); + }); + }); + it('should bulk update schedules for multiple tasks', async () => { const initialTime = Date.now(); const tasks = await Promise.all([ From 19cc3797d36b08bc96fbaa21ece34f037add8559 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 15 Feb 2024 14:28:55 -0400 Subject: [PATCH 18/23] Unskip flaky test from #167643 (#176942) ## Summary This PR unskips the tests skipped in #167643. Flaky test runner x50: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5186. Resolves #167643. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: Julia Rechkunova --- .../test_suites/common/examples/partial_results/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test_serverless/functional/test_suites/common/examples/partial_results/index.ts b/x-pack/test_serverless/functional/test_suites/common/examples/partial_results/index.ts index c5a5bb2995217..415174adf0d0a 100644 --- a/x-pack/test_serverless/functional/test_suites/common/examples/partial_results/index.ts +++ b/x-pack/test_serverless/functional/test_suites/common/examples/partial_results/index.ts @@ -12,8 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common', 'svlCommonPage']); - // FLAKY: https://github.com/elastic/kibana/issues/167643 - describe.skip('Partial Results Example', function () { + describe('Partial Results Example', function () { before(async () => { await PageObjects.svlCommonPage.loginAsAdmin(); await PageObjects.common.navigateToApp('partialResultsExample'); From b4836ace2fb3d6c38df24294732b09be8f713d9f Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 15 Feb 2024 14:29:23 -0400 Subject: [PATCH 19/23] Unskip flaky test from #175579 (#176981) ## Summary This PR unskips the tests skipped in #175579. Flaky test runner x50: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5194. Resolves #175579. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: Julia Rechkunova --- .../test/examples/search_examples/partial_results_example.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test/examples/search_examples/partial_results_example.ts b/x-pack/test/examples/search_examples/partial_results_example.ts index 83eb900ef0ff4..269b2e79ab38f 100644 --- a/x-pack/test/examples/search_examples/partial_results_example.ts +++ b/x-pack/test/examples/search_examples/partial_results_example.ts @@ -14,8 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common']); const retry = getService('retry'); - // FLAKY: https://github.com/elastic/kibana/issues/175579 - describe.skip('Partial results example', () => { + describe('Partial results example', () => { before(async () => { await PageObjects.common.navigateToApp('searchExamples'); await testSubjects.click('/search'); From e136a9318215d5913a5e957aec5d9ad0b8132506 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Thu, 15 Feb 2024 12:30:08 -0600 Subject: [PATCH 20/23] [RAM] Fix bug where select all rules bypasses filters (#176962) ## Summary Fixes #176867 A bug introduced in https://github.com/elastic/kibana/pull/174954 bypassed most filters when using Select All on the Rules List. This was because the names of the filter properties changed, and no longer matched what the `useBulkEditSelect` hook was expecting. Because all of these types were optional, it didn't trigger any type errors. This syncs up the type definitions with the new filter store hook, and adds a functional test to make sure filters are working on bulk actions when clicking the select all button. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../hooks/use_bulk_edit_select.test.tsx | 15 ++-- .../hooks/use_bulk_edit_select.tsx | 55 ++++-------- .../rules_list/components/rules_list.tsx | 3 +- .../rules_list/bulk_actions.ts | 87 ++++++++++++++++++- 4 files changed, 111 insertions(+), 49 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_bulk_edit_select.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_bulk_edit_select.test.tsx index 07bf3516fbdef..1a5e3f278eb5e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_bulk_edit_select.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_bulk_edit_select.test.tsx @@ -45,8 +45,7 @@ describe('useBulkEditSelectTest', () => { useBulkEditSelect({ items, totalItemCount: 4, - tagsFilter: ['test: 123'], - searchText: 'rules*', + filters: { tags: ['test: 123'], searchText: 'rules*' }, }) ); @@ -58,8 +57,7 @@ describe('useBulkEditSelectTest', () => { useBulkEditSelect({ items, totalItemCount: 4, - tagsFilter: ['test: 123'], - searchText: 'rules*', + filters: { tags: ['test: 123'], searchText: 'rules*' }, }) ); @@ -107,8 +105,7 @@ describe('useBulkEditSelectTest', () => { useBulkEditSelect({ items, totalItemCount: 4, - tagsFilter: ['test: 123'], - searchText: 'rules*', + filters: { tags: ['test: 123'], searchText: 'rules*' }, }) ); @@ -124,8 +121,10 @@ describe('useBulkEditSelectTest', () => { useBulkEditSelect({ items, totalItemCount: 4, - tagsFilter: ['test: 123'], - searchText: 'rules*', + filters: { + tags: ['test: 123'], + searchText: 'rules*', + }, }) ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_bulk_edit_select.tsx b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_bulk_edit_select.tsx index 84e762cbe93f8..fe70b4fa0e3bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_bulk_edit_select.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_bulk_edit_select.tsx @@ -7,7 +7,7 @@ import { useReducer, useMemo, useCallback } from 'react'; import { fromKueryExpression, nodeBuilder } from '@kbn/es-query'; import { mapFiltersToKueryNode } from '../lib/rule_api/map_filters_to_kuery_node'; -import { RuleTableItem, RuleStatus } from '../../types'; +import { RuleTableItem, RulesListFilters } from '../../types'; interface BulkEditSelectionState { selectedIds: Set; @@ -73,27 +73,11 @@ const reducer = (state: BulkEditSelectionState, action: Action) => { interface UseBulkEditSelectProps { totalItemCount: number; items: RuleTableItem[]; - typesFilter?: string[]; - actionTypesFilter?: string[]; - tagsFilter?: string[]; - ruleExecutionStatusesFilter?: string[]; - ruleLastRunOutcomesFilter?: string[]; - ruleStatusesFilter?: RuleStatus[]; - searchText?: string; + filters?: RulesListFilters; } export function useBulkEditSelect(props: UseBulkEditSelectProps) { - const { - totalItemCount = 0, - items = [], - typesFilter, - actionTypesFilter, - tagsFilter, - ruleExecutionStatusesFilter, - ruleLastRunOutcomesFilter, - ruleStatusesFilter, - searchText, - } = props; + const { totalItemCount = 0, items = [], filters } = props; const [state, dispatch] = useReducer(reducer, { ...initialState, @@ -187,15 +171,20 @@ export function useBulkEditSelect(props: UseBulkEditSelectProps) { const getFilterKueryNode = useCallback( (idsToExclude?: string[]) => { - const ruleFilterKueryNode = mapFiltersToKueryNode({ - typesFilter, - actionTypesFilter, - tagsFilter, - ruleExecutionStatusesFilter, - ruleLastRunOutcomesFilter, - ruleStatusesFilter, - searchText, - }); + const ruleFilterKueryNode = mapFiltersToKueryNode( + filters + ? { + typesFilter: filters.types, + actionTypesFilter: filters.actionTypes, + tagsFilter: filters.tags, + ruleExecutionStatusesFilter: filters.ruleExecutionStatuses, + ruleLastRunOutcomesFilter: filters.ruleLastRunOutcomes, + ruleParamsFilter: filters.ruleParams, + ruleStatusesFilter: filters.ruleStatuses, + searchText: filters.searchText, + } + : {} + ); if (idsToExclude && idsToExclude.length) { const excludeFilter = fromKueryExpression( @@ -209,15 +198,7 @@ export function useBulkEditSelect(props: UseBulkEditSelectProps) { return ruleFilterKueryNode; }, - [ - typesFilter, - actionTypesFilter, - tagsFilter, - ruleExecutionStatusesFilter, - ruleLastRunOutcomesFilter, - ruleStatusesFilter, - searchText, - ] + [filters] ); const getFilter = useCallback(() => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index 9b66a65949674..b381035fbfc61 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -363,8 +363,7 @@ export const RulesList = ({ } = useBulkEditSelect({ totalItemCount: rulesState.totalItemCount, items: tableItems, - ...filters, - typesFilter: rulesTypesFilter, + filters: { ...filters, types: rulesTypesFilter }, }); const handleUpdateFiltersEffect = useCallback( diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list/bulk_actions.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list/bulk_actions.ts index 4c545e20cb687..c8cf8903a4275 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list/bulk_actions.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list/bulk_actions.ts @@ -14,7 +14,12 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { createAlert, scheduleRule, snoozeAlert } from '../../../lib/alert_api_actions'; +import { + createAlert, + createAlertManualCleanup, + scheduleRule, + snoozeAlert, +} from '../../../lib/alert_api_actions'; import { ObjectRemover } from '../../../lib/object_remover'; export default ({ getPageObjects, getService }: FtrProviderContext) => { @@ -30,7 +35,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('rulesTab'); } - describe('rules list', () => { + describe('rules list bulk actions', () => { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('rulesTab'); @@ -206,5 +211,83 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(toastTitle).to.eql('Updated API key for 1 rule.'); }); }); + + it('should apply filters to bulk actions when using the select all button', async () => { + const rule1 = await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'a' }, + }); + const rule2 = await createAlertManualCleanup({ + supertest, + overwrites: { name: 'b', rule_type_id: 'test.always-firing' }, + }); + const rule3 = await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'c' }, + }); + + await refreshAlertsList(); + expect(await testSubjects.getVisibleText('totalRulesCount')).to.be('3 rules'); + + await testSubjects.click('ruleTypeFilterButton'); + await testSubjects.existOrFail('ruleTypetest.noopFilterOption'); + await testSubjects.click('ruleTypetest.noopFilterOption'); + await testSubjects.click(`checkboxSelectRow-${rule1.id}`); + await testSubjects.click('selectAllRulesButton'); + + await testSubjects.click('showBulkActionButton'); + await testSubjects.click('bulkDisable'); + await testSubjects.existOrFail('untrackAlertsModal'); + await testSubjects.click('confirmModalConfirmButton'); + + await retry.try(async () => { + const toastTitle = await toasts.getTitleAndDismiss(); + expect(toastTitle).to.eql('Disabled 2 rules'); + }); + + await testSubjects.click('rules-list-clear-filter'); + await refreshAlertsList(); + + await pageObjects.triggersActionsUI.searchAlerts(rule1.name); + expect(await testSubjects.getVisibleText('statusDropdown')).to.be('Disabled'); + await pageObjects.triggersActionsUI.searchAlerts(rule2.name); + expect(await testSubjects.getVisibleText('statusDropdown')).to.be('Enabled'); + await pageObjects.triggersActionsUI.searchAlerts(rule3.name); + expect(await testSubjects.getVisibleText('statusDropdown')).to.be('Disabled'); + + await testSubjects.click('rules-list-clear-filter'); + await refreshAlertsList(); + + await testSubjects.click('ruleStatusFilterButton'); + await testSubjects.existOrFail('ruleStatusFilterOption-enabled'); + await testSubjects.click('ruleStatusFilterOption-enabled'); + await testSubjects.click(`checkboxSelectRow-${rule2.id}`); + await testSubjects.click('selectAllRulesButton'); + + await testSubjects.click('showBulkActionButton'); + await testSubjects.click('bulkDelete'); + await testSubjects.existOrFail('rulesDeleteConfirmation'); + await testSubjects.click('confirmModalConfirmButton'); + + await retry.try(async () => { + const toastTitle = await toasts.getTitleAndDismiss(); + expect(toastTitle).to.eql('Deleted 1 rule'); + }); + + await testSubjects.click('rules-list-clear-filter'); + await refreshAlertsList(); + + await retry.try( + async () => { + expect(await testSubjects.getVisibleText('totalRulesCount')).to.be('2 rules'); + }, + async () => { + // If the delete fails, make sure rule2 gets cleaned up + objectRemover.add(rule2.id, 'alert', 'alerts'); + } + ); + }); }); }; From 8e7e5b23ec4f3b148c6a6e33f5b157d6266b710b Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Thu, 15 Feb 2024 20:54:05 +0100 Subject: [PATCH 21/23] Revert changes in PR#175981 (#177054) Reverts changes in https://github.com/elastic/kibana/pull/175981 --- .../rules_client/methods/get_alert_state.ts | 29 +++------ .../tests/get_alert_state.test.ts | 65 ------------------- 2 files changed, 9 insertions(+), 85 deletions(-) diff --git a/x-pack/plugins/alerting/server/rules_client/methods/get_alert_state.ts b/x-pack/plugins/alerting/server/rules_client/methods/get_alert_state.ts index 4da913a06fe79..6497428e1c2f2 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/get_alert_state.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/get_alert_state.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import { RuleTaskState } from '../../types'; import { taskInstanceToAlertTaskInstance } from '../../task_runner/alert_task_instance'; import { ReadOperations, AlertingAuthorizationEntity } from '../../authorization'; @@ -19,28 +18,18 @@ export async function getAlertState( context: RulesClientContext, { id }: GetAlertStateParams ): Promise { - const rule = await get(context, { id }); + const alert = await get(context, { id }); await context.authorization.ensureAuthorized({ - ruleTypeId: rule.alertTypeId, - consumer: rule.consumer, + ruleTypeId: alert.alertTypeId, + consumer: alert.consumer, operation: ReadOperations.GetRuleState, entity: AlertingAuthorizationEntity.Rule, }); - if (rule.scheduledTaskId) { - try { - const { state } = taskInstanceToAlertTaskInstance( - await context.taskManager.get(rule.scheduledTaskId), - rule - ); - return state; - } catch (e) { - if (SavedObjectsErrorHelpers.isNotFoundError(e)) { - context.logger.warn(`Task (${rule.scheduledTaskId}) not found`); - } else { - context.logger.warn( - `An error occurred when getting the task state for (${rule.scheduledTaskId})` - ); - } - } + if (alert.scheduledTaskId) { + const { state } = taskInstanceToAlertTaskInstance( + await context.taskManager.get(alert.scheduledTaskId), + alert + ); + return state; } } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts index 951e85c023523..63d86843512c0 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts @@ -22,7 +22,6 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { getBeforeSetup } from './lib'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; -import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -176,70 +175,6 @@ describe('getAlertState()', () => { expect(taskManager.get).toHaveBeenCalledWith(scheduledTaskId); }); - test('logs a warning if the task not found', async () => { - const rulesClient = new RulesClient(rulesClientParams); - - const scheduledTaskId = 'task-123'; - - unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ - id: '1', - type: RULE_SAVED_OBJECT_TYPE, - attributes: { - alertTypeId: '123', - schedule: { interval: '10s' }, - params: { - bar: true, - }, - actions: [], - enabled: true, - scheduledTaskId, - mutedInstanceIds: [], - muteAll: true, - }, - references: [], - }); - - taskManager.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError()); - - await rulesClient.getAlertState({ id: '1' }); - - expect(rulesClientParams.logger.warn).toHaveBeenCalledTimes(1); - expect(rulesClientParams.logger.warn).toHaveBeenCalledWith('Task (task-123) not found'); - }); - - test('logs a warning if the taskManager throws an error', async () => { - const rulesClient = new RulesClient(rulesClientParams); - - const scheduledTaskId = 'task-123'; - - unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ - id: '1', - type: RULE_SAVED_OBJECT_TYPE, - attributes: { - alertTypeId: '123', - schedule: { interval: '10s' }, - params: { - bar: true, - }, - actions: [], - enabled: true, - scheduledTaskId, - mutedInstanceIds: [], - muteAll: true, - }, - references: [], - }); - - taskManager.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createBadRequestError()); - - await rulesClient.getAlertState({ id: '1' }); - - expect(rulesClientParams.logger.warn).toHaveBeenCalledTimes(1); - expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( - 'An error occurred when getting the task state for (task-123)' - ); - }); - describe('authorization', () => { beforeEach(() => { unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ From d2f566970c22220ba0130ae9446078d30ec8c995 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 15 Feb 2024 16:08:13 -0400 Subject: [PATCH 22/23] Fix flaky test from #172781 (#176972) ## Summary This PR fixes the flaky test skipped in #172781. Flaky test runner x95: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5187. Resolves #172781. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../existing_fields.ts | 2 +- .../page_objects/unified_field_list.ts | 16 ++++++++++++++++ .../existing_fields.ts | 5 ++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/test/examples/unified_field_list_examples/existing_fields.ts b/test/examples/unified_field_list_examples/existing_fields.ts index 341c440b3c8a8..2967e877383f1 100644 --- a/test/examples/unified_field_list_examples/existing_fields.ts +++ b/test/examples/unified_field_list_examples/existing_fields.ts @@ -73,7 +73,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await PageObjects.timePicker.setAbsoluteRange(TEST_START_TIME, TEST_END_TIME); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); - await PageObjects.unifiedFieldList.toggleSidebarSection('meta'); + await PageObjects.unifiedFieldList.openSidebarSection('meta'); }); after(async () => { diff --git a/test/functional/page_objects/unified_field_list.ts b/test/functional/page_objects/unified_field_list.ts index 5e2d1039d7697..378b20cc02e24 100644 --- a/test/functional/page_objects/unified_field_list.ts +++ b/test/functional/page_objects/unified_field_list.ts @@ -88,6 +88,22 @@ export class UnifiedFieldListPageObject extends FtrService { ); } + public async openSidebarSection(sectionName: SidebarSectionName) { + const openedSectionSelector = `${this.getSidebarSectionSelector( + sectionName, + true + )}.euiAccordion-isOpen`; + + if (await this.find.existsByCssSelector(openedSectionSelector)) { + return; + } + + await this.retry.waitFor(`${sectionName} fields section to open`, async () => { + await this.toggleSidebarSection(sectionName); + return await this.find.existsByCssSelector(openedSectionSelector); + }); + } + public async waitUntilFieldPopoverIsOpen() { await this.retry.waitFor('popover is open', async () => { return Boolean(await this.find.byCssSelector('[data-popover-open="true"]')); diff --git a/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts b/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts index 4725b39bd9db1..05400dbac1b38 100644 --- a/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts +++ b/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts @@ -78,7 +78,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await PageObjects.timePicker.setAbsoluteRange(TEST_START_TIME, TEST_END_TIME); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); - await PageObjects.unifiedFieldList.toggleSidebarSection('meta'); + await PageObjects.unifiedFieldList.openSidebarSection('meta'); }); after(async () => { @@ -92,8 +92,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await kibanaServer.savedObjects.cleanStandardList(); }); - // FLAKY: https://github.com/elastic/kibana/issues/172781 - describe.skip('existence', () => { + describe('existence', () => { it('should find which fields exist in the sample documents', async () => { const sidebarFields = await PageObjects.unifiedFieldList.getAllFieldNames(); expect(sidebarFields.sort()).to.eql([...metaFields, ...fieldsWithData].sort()); From cec949ed0abfe4cd2a9f74d55c0d3212e96db328 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Thu, 15 Feb 2024 13:09:11 -0700 Subject: [PATCH 23/23] Deprecates SavedObjectType migrations and schemas in favor of modelVersions (#176970) fix https://github.com/elastic/kibana/issues/176776 Saved objects `modelVersions` support BWC and ZDT and replace `SavedObjectsType.schemas` and `SavedObjectsType.migrations` properties. This PR marks these two properties as deprecated. The public facing docs have also been updated to the "new" way of implementing saved object changes. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials --- ...objects-service-use-case-examples.asciidoc | 761 ++++++++++++++++++ .../core/saved-objects-service.asciidoc | 513 +++++++----- .../src/saved_objects_type.ts | 4 +- 3 files changed, 1068 insertions(+), 210 deletions(-) create mode 100644 docs/developer/architecture/core/saved-objects-service-use-case-examples.asciidoc diff --git a/docs/developer/architecture/core/saved-objects-service-use-case-examples.asciidoc b/docs/developer/architecture/core/saved-objects-service-use-case-examples.asciidoc new file mode 100644 index 0000000000000..2b2cbde0b3f1a --- /dev/null +++ b/docs/developer/architecture/core/saved-objects-service-use-case-examples.asciidoc @@ -0,0 +1,761 @@ +[[saved-objects-service-use-case-examples]] +=== Use-case examples + +These are example of the migration scenario currently supported (out of +the box) by the system. + +*note:* _more complex scenarios (e.g field mutation by copy/sync) could +already be implemented, but without the proper tooling exposed from +Core, most of the work related to sync and compatibility would have to +be implemented in the domain layer of the type owners, which is why +we’re not documenting them yet._ + +==== Adding a non-indexed field without default value + +We are currently in model version 1, and our type has 2 indexed fields +defined: `foo` and `bar`. + +The definition of the type at version 1 would look like: + +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + namespaceType: 'single', + switchToModelVersionAt: '8.10.0', + modelVersions: { + // initial (and current) model version + 1: { + changes: [], + schemas: { + // FC schema defining the known fields (indexed or not) for this version + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string() }, + { unknowns: 'ignore' } // note the `unknown: ignore` which is how we're evicting the unknown fields + ), + // schema that will be used to validate input during `create` and `bulkCreate` + create: schema.object( + { foo: schema.string(), bar: schema.string() }, + ) + }, + }, + }, + mappings: { + properties: { + foo: { type: 'text' }, + bar: { type: 'text' }, + }, + }, +}; +---- + +From here, say we want to introduce a new `dolly` field that is not +indexed, and that we don’t need to populate with a default value. + +To achieve that, we need to introduce a new model version, with the only +thing to do will be to define the associated schemas to include this new +field. + +The added model version would look like: + +[source,ts] +---- +// the new model version adding the `dolly` field +let modelVersion2: SavedObjectsModelVersion = { + // not an indexed field, no data backfill, so changes are actually empty + changes: [], + schemas: { + // the only addition in this model version: taking the new field into account for the schemas + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + { unknowns: 'ignore' } // note the `unknown: ignore` which is how we're evicting the unknown fields + ), + create: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + ) + }, +}; +---- + +The full type definition after the addition of the new model version: + +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + namespaceType: 'single', + switchToModelVersionAt: '8.10.0', + modelVersions: { + 1: { + changes: [], + schemas: { + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { foo: schema.string(), bar: schema.string() }, + ) + }, + }, + 2: { + changes: [], + schemas: { + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + ) + }, + }, + }, + mappings: { + properties: { + foo: { type: 'text' }, + bar: { type: 'text' }, + }, + }, +}; +---- + +==== Adding an indexed field without default value + +This scenario is fairly close to the previous one. The difference being +that working with an indexed field means adding a `mappings_addition` +change and to also update the root mappings accordingly. + +To reuse the previous example, let’s say the `dolly` field we want to +add would need to be indexed instead. + +In that case, the new version needs to do the following: - add a +`mappings_addition` type change to define the new mappings - update the +root `mappings` accordingly - add the updated schemas as we did for the +previous example + +The new version definition would look like: + +[source,ts] +---- +let modelVersion2: SavedObjectsModelVersion = { + // add a change defining the mapping for the new field + changes: [ + { + type: 'mappings_addition', + addedMappings: { + dolly: { type: 'text' }, + }, + }, + ], + schemas: { + // adding the new field to the forwardCompatibility schema + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + ) + }, +}; +---- + +As said, we will also need to update the root mappings definition: + +[source,ts] +---- +mappings: { + properties: { + foo: { type: 'text' }, + bar: { type: 'text' }, + dolly: { type: 'text' }, + }, +}, +---- + +the full type definition after the addition of the model version 2 would +be: + +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + namespaceType: 'single', + switchToModelVersionAt: '8.10.0', + modelVersions: { + 1: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + foo: { type: 'text' }, + bar: { type: 'text' }, + }, + }, + ], + schemas: { + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { foo: schema.string(), bar: schema.string() }, + ) + }, + }, + 2: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + dolly: { type: 'text' }, + }, + }, + ], + schemas: { + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + ) + }, + }, + }, + mappings: { + properties: { + foo: { type: 'text' }, + bar: { type: 'text' }, + dolly: { type: 'text' }, + }, + }, +}; +---- + +==== Adding an indexed field with a default value + +Now a slightly different scenario where we’d like to populate the newly +introduced field with a default value. + +In that case, we’d need to add an additional `data_backfill` change to +populate the new field’s value (in addition to the `mappings_addition` +one): + +[source,ts] +---- +let modelVersion2: SavedObjectsModelVersion = { + changes: [ + // setting the `dolly` field's default value. + { + type: 'data_backfill', + transform: (document) => { + return { attributes: { dolly: 'default_value' } }; + }, + }, + // define the mappings for the new field + { + type: 'mappings_addition', + addedMappings: { + dolly: { type: 'text' }, + }, + }, + ], + schemas: { + // define `dolly` as an know field in the schema + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + ) + }, +}; +---- + +The full type definition would look like: + +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + namespaceType: 'single', + switchToModelVersionAt: '8.10.0', + modelVersions: { + 1: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + foo: { type: 'text' }, + bar: { type: 'text' }, + }, + }, + ], + schemas: { + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { foo: schema.string(), bar: schema.string() }, + ) + }, + }, + 2: { + changes: [ + { + type: 'data_backfill', + transform: (document) => { + return { attributes: { dolly: 'default_value' } }; + }, + }, + { + type: 'mappings_addition', + addedMappings: { + dolly: { type: 'text' }, + }, + }, + ], + schemas: { + forwardCompatibility: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { foo: schema.string(), bar: schema.string(), dolly: schema.string() }, + ) + }, + }, + }, + mappings: { + properties: { + foo: { type: 'text' }, + bar: { type: 'text' }, + dolly: { type: 'text' }, + }, + }, +}; +---- + +*Note:* _if the field was non-indexed, we would just not use the +`mappings_addition` change or update the mappings (as done in example +1)_ + +==== Removing an existing field + +We are currently in model version 1, and our type has 2 indexed fields +defined: `kept` and `removed`. + +The definition of the type at version 1 would look like: + +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + namespaceType: 'single', + switchToModelVersionAt: '8.10.0', + modelVersions: { + // initial (and current) model version + 1: { + changes: [], + schemas: { + // FC schema defining the known fields (indexed or not) for this version + forwardCompatibility: schema.object( + { kept: schema.string(), removed: schema.string() }, + { unknowns: 'ignore' } // note the `unknown: ignore` which is how we're evicting the unknown fields + ), + // schema that will be used to validate input during `create` and `bulkCreate` + create: schema.object( + { kept: schema.string(), removed: schema.string() }, + ) + }, + }, + }, + mappings: { + properties: { + kept: { type: 'text' }, + removed: { type: 'text' }, + }, + }, +}; +---- + +From here, say we want to remove the `removed` field, as our application +doesn’t need it anymore since a recent change. + +The first thing to understand here is the impact toward backward +compatibility: Say that Kibana version `X` was still using this field, +and that we stopped utilizing the field in version `X+1`. + +We can’t remove the data in version `X+1`, as we need to be able to +rollback to the prior version at *any time*. If we were to delete the +data of this `removed` field during the upgrade to version `X+1`, and if +then, for any reason, we’d need to rollback to version `X`, it would +cause a data loss, as version `X` was still using this field, but it +would no longer present in our document after the rollback. + +Which is why we need to perform any field removal as a 2-step operation: +- release `X`: Kibana still utilize the field - release `X+1`: Kibana no +longer utilize the field, but the data is still present in the documents +- release `X+2`: The data is effectively deleted from the documents. + +That way, any prior-version rollback (`X+2` to `X+1` *or* `X+1` to `X` +is safe in term of data integrity) + +The main question then, is what’s the best way of having our application +layer simply ignore this `removed` field during version `X+1`, as we +don’t want this field (now non-utilized) to be returned from the +persistence layer, as it could ``pollute'' the higher-layers where the +field is effectively no longer used or even known. + +This can easily be done by introducing a new version and using the +`forwardCompatibility` schema to ``shallow'' the field. + +The `X+1` model version would look like: + +[source,ts] +---- +// the new model version ignoring the `removed` field +let modelVersion2: SavedObjectsModelVersion = { + changes: [], + schemas: { + forwardCompatibility: schema.object( + { kept: schema.string() }, // `removed` is no longer defined here + { unknowns: 'ignore' } + ), + create: schema.object( + { kept: schema.string() }, // `removed` is no longer defined here + ) + }, +}; +---- + +The full type definition after the addition of the new model version: + +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + namespaceType: 'single', + switchToModelVersionAt: '8.10.0', + modelVersions: { + // initial (and current) model version + 1: { + changes: [], + schemas: { + // FC schema defining the known fields (indexed or not) for this version + forwardCompatibility: schema.object( + { kept: schema.string(), removed: schema.string() }, + { unknowns: 'ignore' } // note the `unknown: ignore` which is how we're evicting the unknown fields + ), + // schema that will be used to validate input during `create` and `bulkCreate` + create: schema.object( + { kept: schema.string(), removed: schema.string() }, + ) + }, + }, + 2: { + changes: [], + schemas: { + forwardCompatibility: schema.object( + { kept: schema.string() }, // `removed` is no longer defined here + { unknowns: 'ignore' } + ), + create: schema.object( + { kept: schema.string() }, // `removed` is no longer defined here + ) + }, + } + }, + mappings: { + properties: { + kept: { type: 'text' }, + removed: { type: 'text' }, + }, + }, +}; +---- + +then, in a *later* release, we can then deploy the change that will +effectively remove the data from the documents: + +[source,ts] +---- +// the new model version ignoring the `removed` field +let modelVersion3: SavedObjectsModelVersion = { + changes: [ // define a data_removal change to delete the field + { + type: 'data_removal', + removedAttributePaths: ['removed'] + } + ], + schemas: { + forwardCompatibility: schema.object( + { kept: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { kept: schema.string() }, + ) + }, +}; +---- + +The full type definition after the data removal would look like: + +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + namespaceType: 'single', + switchToModelVersionAt: '8.10.0', + modelVersions: { + // initial (and current) model version + 1: { + changes: [], + schemas: { + // FC schema defining the known fields (indexed or not) for this version + forwardCompatibility: schema.object( + { kept: schema.string(), removed: schema.string() }, + { unknowns: 'ignore' } // note the `unknown: ignore` which is how we're evicting the unknown fields + ), + // schema that will be used to validate input during `create` and `bulkCreate` + create: schema.object( + { kept: schema.string(), removed: schema.string() }, + ) + }, + }, + 2: { + changes: [], + schemas: { + forwardCompatibility: schema.object( + { kept: schema.string() }, // `removed` is no longer defined here + { unknowns: 'ignore' } + ), + create: schema.object( + { kept: schema.string() }, // `removed` is no longer defined here + ) + }, + }, + 3: { + changes: [ // define a data_removal change to delete the field + { + type: 'data_removal', + removedAttributePaths: ['removed'] + } + ], + schemas: { + forwardCompatibility: schema.object( + { kept: schema.string() }, + { unknowns: 'ignore' } + ), + create: schema.object( + { kept: schema.string() }, + ) + }, + } + }, + mappings: { + properties: { + kept: { type: 'text' }, + removed: { type: 'text' }, + }, + }, +}; +---- + +=== Testing model versions + +Model versions definitions are more structured than the legacy migration +functions, which makes them harder to test without the proper tooling. +This is why a set of testing tools and utilities are exposed from the +`@kbn/core-test-helpers-model-versions` package, to help properly test +the logic associated with model version and their associated +transformations. + +==== Tooling for unit tests + +For unit tests, the package exposes utilities to easily test the impact +of transforming documents from a model version to another one, either +upward or backward. + +===== Model version test migrator + +The `createModelVersionTestMigrator` helper allows to create a test +migrator that can be used to test model version changes between +versions, by transforming documents the same way the migration algorithm +would during an upgrade. + +*Example:* + +[source,ts] +---- +import { + createModelVersionTestMigrator, + type ModelVersionTestMigrator +} from '@kbn/core-test-helpers-model-versions'; + +const mySoTypeDefinition = someSoType(); + +describe('mySoTypeDefinition model version transformations', () => { + let migrator: ModelVersionTestMigrator; + + beforeEach(() => { + migrator = createModelVersionTestMigrator({ type: mySoTypeDefinition }); + }); + + describe('Model version 2', () => { + it('properly backfill the expected fields when converting from v1 to v2', () => { + const obj = createSomeSavedObject(); + + const migrated = migrator.migrate({ + document: obj, + fromVersion: 1, + toVersion: 2, + }); + + expect(migrated.properties).toEqual(expectedV2Properties); + }); + + it('properly removes the expected fields when converting from v2 to v1', () => { + const obj = createSomeSavedObject(); + + const migrated = migrator.migrate({ + document: obj, + fromVersion: 2, + toVersion: 1, + }); + + expect(migrated.properties).toEqual(expectedV1Properties); + }); + }); +}); +---- + +==== Tooling for integration tests + +During integration tests, we can boot a real Elasticsearch cluster, +allowing us to manipulate SO documents in a way almost similar to how it +would be done on production runtime. With integration tests, we can even +simulate the cohabitation of two Kibana instances with different model +versions to assert the behavior of their interactions. + +===== Model version test bed + +The package exposes a `createModelVersionTestBed` function that can be +used to fully setup a test bed for model version integration testing. It +can be used to start and stop the ES server, and to initiate the +migration between the two versions we’re testing. + +*Example:* + +[source,ts] +---- +import { + createModelVersionTestBed, + type ModelVersionTestKit +} from '@kbn/core-test-helpers-model-versions'; + +describe('myIntegrationTest', () => { + const testbed = createModelVersionTestBed(); + let testkit: ModelVersionTestKit; + + beforeAll(async () => { + await testbed.startES(); + }); + + afterAll(async () => { + await testbed.stopES(); + }); + + beforeEach(async () => { + // prepare the test, preparing the index and performing the SO migration + testkit = await testbed.prepareTestKit({ + savedObjectDefinitions: [{ + definition: mySoTypeDefinition, + // the model version that will be used for the "before" version + modelVersionBefore: 1, + // the model version that will be used for the "after" version + modelVersionAfter: 2, + }] + }) + }); + + afterEach(async () => { + if(testkit) { + // delete the indices between each tests to perform a migration again + await testkit.tearsDown(); + } + }); + + it('can be used to test model version cohabitation', async () => { + // last registered version is `1` (modelVersionBefore) + const repositoryV1 = testkit.repositoryBefore; + // last registered version is `2` (modelVersionAfter) + const repositoryV2 = testkit.repositoryAfter; + + // do something with the two repositories, e.g + await repositoryV1.create(someAttrs, { id }); + const v2docReadFromV1 = await repositoryV2.get('my-type', id); + expect(v2docReadFromV1.attributes).toEqual(whatIExpect); + }); +}); +---- + +*Limitations:* + +Because the test bed is only creating the parts of Core required to +instantiate the two SO repositories, and because we’re not able to +properly load all plugins (for proper isolation), the integration test +bed currently has some limitations: + +* no extensions are enabled +** no security +** no encryption +** no spaces +* all SO types will be using the same SO index + +=== Limitations and edge cases in serverless environments + +The serverless environment, and the fact that upgrade in such +environments are performed in a way where, at some point, the old and +new version of the application are living in cohabitation, leads to some +particularities regarding the way the SO APIs works, and to some +limitations / edge case that we need to document + +==== Using the `fields` option of the `find` savedObjects API + +By default, the `find` API (as any other SO API returning documents) +will migrate all documents before returning them, to ensure that +documents can be used by both versions during a cohabitation (e.g an old +node searching for documents already migrated, or a new node searching +for documents not yet migrated). + +However, when using the `fields` option of the `find` API, the documents +can’t be migrated, as some model version changes can’t be applied +against a partial set of attributes. For this reason, when the `fields` +option is provided, the documents returned from `find` will *not* be +migrated. + +Which is why, when using this option, the API consumer needs to make +sure that _all_ the fields passed to the `fields` option *were already +present in the prior model version*. Otherwise, it may lead to +inconsistencies during upgrades, where newly introduced or backfilled +fields may not necessarily appear in the documents returned from the +`search` API when the option is used. + +(_note_: both the previous and next version of Kibana must follow this +rule then) + +==== Using `bulkUpdate` for fields with large `json` blobs + +The savedObjects `bulkUpdate` API will update documents client-side and +then reindex the updated documents. These update operations are done +in-memory, and cause memory constraint issues when updating many objects +with large `json` blobs stored in some fields. As such, we recommend +against using `bulkUpdate` for savedObjects that: - use arrays (as these +tend to be large objects) - store large `json` blobs in some fields + diff --git a/docs/developer/architecture/core/saved-objects-service.asciidoc b/docs/developer/architecture/core/saved-objects-service.asciidoc index eb9f1c7fd4516..71ece4ae3d735 100644 --- a/docs/developer/architecture/core/saved-objects-service.asciidoc +++ b/docs/developer/architecture/core/saved-objects-service.asciidoc @@ -47,6 +47,11 @@ export const dashboardVisualization: SavedObjectsType = { name: 'dashboard_visualization', // <1> hidden: true, namespaceType: 'multiple-isolated', // <2> + switchToModelVersionAt: '8.10.0', + modelVersions: { + 1: modelVersion1, + 2: modelVersion2, + }, mappings: { dynamic: false, properties: { @@ -58,10 +63,7 @@ export const dashboardVisualization: SavedObjectsType = { }, }, }, - migrations: { - '1.0.0': migratedashboardVisualizationToV1, - '2.0.0': migratedashboardVisualizationToV2, - }, + // ...other mandatory properties }; ---- <1> Since the name of a Saved Object type may form part of the URL path for the @@ -95,33 +97,32 @@ Each Saved Object type can define it's own {es} field mappings. Because multiple Saved Object types can share the same index, mappings defined by a type will be nested under a top-level field that matches the type name. -For example, the mappings defined by the `dashboard_visualization` Saved +For example, the mappings defined by the `search` Saved Object type: -.src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts +https://github.com/elastic/kibana/blob/main/src/plugins/saved_search/server/saved_objects/search.ts#L19-L70[.src/plugins/saved_search/server/saved_objects/search.ts] [source,typescript] ---- import { SavedObjectsType } from 'src/core/server'; - -export const dashboardVisualization: SavedObjectsType = { - name: 'dashboard_visualization', - ... +// ... other imports +export function getSavedSearchObjectType: SavedObjectsType = { // <1> + name: 'search', + hidden: false, + namespaceType: 'multiple-isolated', mappings: { + dynamic: false, properties: { - dynamic: false, - description: { - type: 'text', - }, - hits: { - type: 'integer', - }, + title: { type: 'text' }, + description: { type: 'text' }, }, }, - migrations: { ... }, + modelVersions: { ... }, + // ...other optional properties }; ---- +<1> Simplification -Will result in the following mappings being applied to the `.kibana` index: +Will result in the following mappings being applied to the `.kibana_analytics` index: [source,json] ---- { @@ -129,14 +130,14 @@ Will result in the following mappings being applied to the `.kibana` index: "dynamic": "strict", "properties": { ... - "dashboard_vizualization": { + "search": { "dynamic": false, "properties": { - "description": { + "title": { "type": "text", }, - "hits": { - "type": "integer", + "description": { + "type": "text", }, }, } @@ -157,242 +158,336 @@ Saved Object types should never use `dynamic: true` as this can cause an arbitrary amount of fields to be added to the `.kibana` index. [[saved-objects-service-writing-migrations]] -==== Writing Migrations +==== Writing Migrations by defining model versions -Saved Objects support schema changes between Kibana versions, which we call -migrations. Migrations are applied when a Kibana installation is upgraded from -one version to the next, when exports are imported via the Saved Objects -Management UI, or when a new object is created via the HTTP API. +Saved Objects support changes using `modelVersions``. The modelVersion API is a new way to define transformations +(_``migrations''_) for your savedObject types, and will replace the +``legacy'' migration API after Kibana version `8.10.0`. The legacy migration API has been deprecated, meaning it is no longer possible to register migrations using the legacy system. -Each Saved Object type may define migrations for its schema. Migrations are -specified by the Kibana version number, receive an input document, and must -return the fully migrated document to be persisted to Elasticsearch. +Model versions are decoupled from the stack version and satisfy the requirements for zero downtime and backward-compatibility. -Let's say we want to define two migrations: -- In version 1.1.0, we want to drop the `subtitle` field and append it to the - title -- In version 1.4.0, we want to add a new `id` field to every panel with a newly - generated UUID. +Each Saved Object type may define model versions for its schema and are bound to a given https://github.com/elastic/kibana/blob/9b330e493216e8dde3166451e4714966f63f5ab7/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts#L22-L27[savedObject type]. Changes to a saved object type are +specified by defining a new model. -First, the current `mappings` should always reflect the latest or "target" -schema. Next, we should define a migration function for each step in the schema -evolution: +=== Defining model versions -src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts -[source,typescript] +As for old migrations, model versions are bound to a given +https://github.com/elastic/kibana/blob/9b330e493216e8dde3166451e4714966f63f5ab7/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts#L22-L27[savedObject +type] + +When registering a SO type, a new +https://github.com/elastic/kibana/blob/9a6a2ccdff619f827b31c40dd9ed30cb27203da7/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts#L138-L177[modelVersions] +property is available. This attribute is a map of +https://github.com/elastic/kibana/blob/9a6a2ccdff619f827b31c40dd9ed30cb27203da7/packages/core/saved-objects/core-saved-objects-server/src/model_version/model_version.ts#L12-L20[SavedObjectsModelVersion] +which is the top-level type/container to define model versions. + +This map follows a similar `{ [version number] => version definition }` +format as the old migration map, however a given SO type’s model version +is now identified by a single integer. + +The first version must be numbered as version 1, incrementing by one for +each new version. + +That way: - SO type versions are decoupled from stack versioning - SO +type versions are independent between types + +_a *valid* version numbering:_ + +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + switchToModelVersionAt: '8.10.0', + modelVersions: { + 1: modelVersion1, // valid: start with version 1 + 2: modelVersion2, // valid: no gap between versions + }, + // ...other mandatory properties +}; ---- -import { SavedObjectsType, SavedObjectMigrationFn } from 'src/core/server'; -import uuid from 'uuid'; -interface DashboardVisualizationPre110 { - title: string; - subtitle: string; - panels: Array<{}>; -} -interface DashboardVisualization110 { - title: string; - panels: Array<{}>; -} +_an *invalid* version numbering:_ -interface DashboardVisualization140 { - title: string; - panels: Array<{ id: string }>; -} +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + switchToModelVersionAt: '8.10.0', + modelVersions: { + 2: modelVersion2, // invalid: first version must be 1 + 4: modelVersion3, // invalid: skipped version 3 + }, + // ...other mandatory properties +}; +---- -const migrateDashboardVisualization110: SavedObjectMigrationFn< - DashboardVisualizationPre110, // <1> - DashboardVisualization110 -> = (doc) => { - const { subtitle, ...attributesWithoutSubtitle } = doc.attributes; - return { - ...doc, // <2> - attributes: { - ...attributesWithoutSubtitle, - title: `${doc.attributes.title} - ${doc.attributes.subtitle}`, +=== Structure of a model version + +https://github.com/elastic/kibana/blob/9b330e493216e8dde3166451e4714966f63f5ab7/packages/core/saved-objects/core-saved-objects-server/src/model_version/model_version.ts#L12-L20[Model +versions] are not just functions as the previous migrations were, but +structured objects describing how the version behaves and what changed +since the last one. + +_A base example of what a model version can look like:_ + +[source,ts] +---- +const myType: SavedObjectsType = { + name: 'test', + switchToModelVersionAt: '8.10.0', + modelVersions: { + 1: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + someNewField: { type: 'text' }, + }, + }, + { + type: 'data_backfill', + transform: someBackfillFunction, + }, + ], + schemas: { + forwardCompatibility: fcSchema, + create: createSchema, + }, }, - }; + }, + // ...other mandatory properties }; +---- + +*Note:* Having multiple changes of the same type for a given version is +supported by design to allow merging different sources (to prepare for +an eventual higher-level API) -const migrateDashboardVisualization140: SavedObjectMigrationFn< - DashboardVisualization110, - DashboardVisualization140 -> = (doc) => { - const outPanels = doc.attributes.panels?.map((panel) => { - return { ...panel, id: uuid.v4() }; - }); - return { - ...doc, - attributes: { - ...doc.attributes, - panels: outPanels, +_This definition would be perfectly valid:_ + +[source,ts] +---- +const version1: SavedObjectsModelVersion = { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + someNewField: { type: 'text' }, + }, + }, + { + type: 'mappings_addition', + addedMappings: { + anotherNewField: { type: 'text' }, + }, }, - }; + ], }; +---- -export const dashboardVisualization: SavedObjectsType = { - name: 'dashboard_visualization', // <1> - /** ... */ - migrations: { - // Takes a pre 1.1.0 doc, and converts it to 1.1.0 - '1.1.0': migrateDashboardVisualization110, +It’s currently composed of two main properties: + +==== changes + +https://github.com/elastic/kibana/blob/9b330e493216e8dde3166451e4714966f63f5ab7/packages/core/saved-objects/core-saved-objects-server/src/model_version/model_version.ts#L21-L51[link +to the TS doc for `changes`] + +Describes the list of changes applied during this version. + +*Important:* This is the part that replaces the old migration system, +and allows defining when a version adds new mapping, mutates the +documents, or other type-related changes. + +The current types of changes are: + +===== - mappings_addition + +Used to define new mappings introduced in a given version. - // Takes a 1.1.0 doc, and converts it to 1.4.0 - '1.4.0': migrateDashboardVisualization140, // <3> +_Usage example:_ + +[source,ts] +---- +const change: SavedObjectsModelMappingsAdditionChange = { + type: 'mappings_addition', + addedMappings: { + newField: { type: 'text' }, + existingNestedField: { + properties: { + newNestedProp: { type: 'keyword' }, + }, + }, }, }; ---- -<1> It is useful to define an interface for each version of the schema. This -allows TypeScript to ensure that you are properly handling the input and output -types correctly as the schema evolves. -<2> Returning a shallow copy is necessary to avoid type errors when using -different types for the input and output shape. -<3> Migrations do not have to be defined for every version. The version number -of a migration must always be the earliest Kibana version in which this -migration was released. So if you are creating a migration which will be -part of the v7.10.0 release, but will also be backported and released as -v7.9.3, the migration version should be: 7.9.3. -Migrations should be written defensively, an exception in a migration function -will prevent a Kibana upgrade from succeeding and will cause downtime for our -users. Having said that, if a document is encountered that is not in the -expected shape, migrations are encouraged to throw an exception to abort the -upgrade. In most scenarios, it is better to fail an upgrade than to silently -ignore a corrupt document which can cause unexpected behaviour at some future -point in time. +*note:* _When adding mappings, the root `type.mappings` must also be +updated accordingly (as it was done previously)._ -WARNING: Do not attempt to change the `migrationVersion`, `id`, or `type` fields -within a migration function, this is not supported. +===== - mappings_deprecation -It is critical that you have extensive tests to ensure that migrations behave -as expected with all possible input documents. Given how simple it is to test -all the branch conditions in a migration function and the high impact of a bug -in this code, there's really no reason not to aim for 100% test code coverage. +Used to flag mappings as no longer being used and ready to be removed. -==== Type visibility -It is recommended that plugins only expose Saved Object types that are necessary. -That is so to provide better backward compatibility. +_Usage example:_ -There are two options to register a type: either as completely unexposed to the global Saved Objects HTTP APIs and client or to only expose it to the client but not to the APIs. - -===== Hidden types +[source,ts] +---- +let change: SavedObjectsModelMappingsDeprecationChange = { + type: 'mappings_deprecation', + deprecatedMappings: ['someDeprecatedField', 'someNested.deprecatedField'], +}; +---- -In case when the type is not hidden, it will be exposed via the global Saved Objects HTTP API. -That brings the limitation of introducing backward incompatible changes as there might be a service consuming the API. +*note:* _It is currently not possible to remove fields from an existing +index’s mapping (without reindexing into another index), so the mappings +flagged with this change type won’t be deleted for now, but this should +still be used to allow our system to clean the mappings once upstream +(ES) unblock us._ -This is a formal limitation not prohibiting backward incompatible changes in the migrations. -But in case of such changes on the hidden types, they can be resolved and encapsulated on the intermediate layer in the plugin API. +===== - data_backfill -Hence, the main idea is that all the interactions with the Saved Objects should be done via the plugin API rather than via the Saved Objects HTTP API. +Used to populate fields (indexed or not) added in the same version. -By default, the hidden types will not be accessible in the Saved Objects client. -To make them accessible, they should be explicitly listed in the `includedHiddenTypes` parameters upon client creation. +_Usage example:_ -[source,typescript] +[source,ts] +---- +let change: SavedObjectsModelDataBackfillChange = { + type: 'data_backfill', + transform: (document) => { + return { attributes: { someAddedField: 'defaultValue' } }; + }, +}; ---- -import { CoreStart, Plugin } from '@kbn/core/server'; -class SomePlugin implements Plugin { - // ... +*note:* _Even if no check is performed to ensure it, this type of model +change should only be used to backfill newly introduced fields._ - public start({ savedObjects }: CoreStart) { - // ... +===== - data_removal - const savedObjectsClient = savedObjects.getScopedClient( - request, - { includedHiddenTypes: ['dashboard_visualization'] } - ); +Used to remove data (unset fields) from all documents of the type. - // ... - } -} +_Usage example:_ + +[source,ts] +---- +let change: SavedObjectsModelDataRemovalChange = { + type: 'data_removal', + attributePaths: ['someRootAttributes', 'some.nested.attribute'], +}; ---- -===== Hidden from the HTTP APIs +*note:* _Due to backward compatibility, field utilization must be +stopped in a prior release before actual data removal (in case of +rollback). Please refer to the field removal migration example below in +this document_ -When a saved object is registered as hidden from the HTTP APIs, it will remain exposed to the global Saved Objects client: +===== - unsafe_transform -[source,typescript] ----- -import { SavedObjectsType } from 'src/core/server'; +Used to execute an arbitrary transformation function. -export const myCustomVisualization: SavedObjectsType = { - name: 'my_custom_visualization', // <1> - hidden: false, - hiddenFromHttpApis: true, // <2> - namespaceType: 'multiple-isolated', - mappings: { - dynamic: false, - properties: { - description: { - type: 'text', - }, - hits: { - type: 'integer', - }, - }, - }, - migrations: { - '1.0.0': migrateMyCustomVisualizationToV1, - '2.0.0': migrateMyCustomVisualizationToV2, +_Usage example:_ + +[source,ts] +---- +let change: SavedObjectsModelUnsafeTransformChange = { + type: 'unsafe_transform', + transformFn: (document) => { + document.attributes.someAddedField = 'defaultValue'; + return { document }; }, }; ---- -<1> MyCustomVisualization types have their own domain-specific HTTP API's that leverage the global Saved Objects client -<2> This field determines "hidden from http apis" behavior -- any attempts to use the global Saved Objects HTTP APIs will throw errors +*note:* _Using such transformations is potentially unsafe, given the +migration system will have no knowledge of which kind of operations will +effectively be executed against the documents. Those should only be used +when there’s no other way to cover one’s migration needs._ *Please reach +out to the development team if you think you need to use this, as you +theoretically shouldn’t.* + +==== schemas + +https://github.com/elastic/kibana/blob/9b330e493216e8dde3166451e4714966f63f5ab7/packages/core/saved-objects/core-saved-objects-server/src/model_version/schemas.ts#L11-L16[link +to the TS doc for `schemas`] + +The schemas associated with this version. Schemas are used to validate +or convert SO documents at various stages of their lifecycle. -=== Client side usage +The currently available schemas are: -==== References +===== forwardCompatibility -When a Saved Object declares `references` to other Saved Objects, the -Saved Objects Export API will automatically export the target object with all -of its references. This makes it easy for users to export the entire -reference graph of an object. +This is a new concept introduced by model versions. This schema is used +for inter-version compatibility. -If a Saved Object can't be used on its own, that is, it needs other objects -to exist for a feature to function correctly, that Saved Object should declare -references to all the objects it requires. For example, a `dashboard` -object might have panels for several `visualization` objects. When these -`visualization` objects don't exist, the dashboard cannot be rendered -correctly. The `dashboard` object should declare references to all its -visualizations. +When retrieving a savedObject document from an index, if the version of +the document is higher than the latest version known of the Kibana +instance, the document will go through the `forwardCompatibility` schema +of the associated model version. -However, `visualization` objects can continue to be rendered or embedded into -other dashboards even if the `dashboard` it was originally embedded into -doesn't exist. As a result, `visualization` objects should not declare -references to `dashboard` objects. +*Important:* These conversion mechanism shouldn’t assert the data +itself, and only strip unknown fields to convert the document to the +*shape* of the document at the given version. -For each referenced object, an `id`, `type` and `name` are added to the -`references` array: +Basically, this schema should keep all the known fields of a given +version, and remove all the unknown fields, without throwing. -[source, typescript] +Forward compatibility schema can be implemented in two different ways. + +[arabic] +. Using `config-schema` + +_Example of schema for a version having two fields: someField and +anotherField_ + +[source,ts] ---- -router.get( - { path: '/some-path', validate: false }, - async (context, req, res) => { - const object = await context.core.savedObjects.client.create( - 'dashboard', - { - title: 'my dashboard', - panels: [ - { visualization: 'vis1' }, // <1> - ], - indexPattern: 'indexPattern1' - }, - { references: [ - { id: '...', type: 'visualization', name: 'vis1' }, - { id: '...', type: 'index_pattern', name: 'indexPattern1' }, - ] - } - ) - ... - } +const versionSchema = schema.object( + { + someField: schema.maybe(schema.string()), + anotherField: schema.maybe(schema.string()), + }, + { unknowns: 'ignore' } ); ---- -<1> Note how `dashboard.panels[0].visualization` stores the `name` property of -the reference (not the `id` directly) to be able to uniquely identify this -reference. This guarantees that the id the reference points to always remains -up to date. If a visualization `id` was directly stored in -`dashboard.panels[0].visualization` there is a risk that this `id` gets -updated without updating the reference in the references array. + +*Important:* Note the `{ unknowns: 'ignore' }` in the schema’s options. +This is required when using `config-schema` based schemas, as this what +will evict the additional fields without throwing an error. + +[arabic, start=2] +. Using a plain javascript function + +_Example of schema for a version having two fields: someField and +anotherField_ + +[source,ts] +---- +const versionSchema: SavedObjectModelVersionEvictionFn = (attributes) => { + const knownFields = ['someField', 'anotherField']; + return pick(attributes, knownFields); +} +---- + +*note:* _Even if highly recommended, implementing this schema is not +strictly required. Type owners can manage unknown fields and +inter-version compatibility themselves in their service layer instead._ + +===== create + +This is a direct replacement for +https://github.com/elastic/kibana/blob/9b330e493216e8dde3166451e4714966f63f5ab7/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts#L75-L82[the +old SavedObjectType.schemas] definition, now directly included in the +model version definition. + +As a refresher the `create` schema is a `@kbn/config-schema` object-type +schema, and is used to validate the properties of the document during +`create` and `bulkCreate` operations. + +*note:* _Implementing this schema is optional, but still recommended, as +otherwise there will be no validating when importing objects_ + +For implementation examples, refer to <>. + +include::saved-objects-service-use-case-examples.asciidoc[leveloffset=+1] diff --git a/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts b/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts index 716b31406649c..53b52d04f376b 100644 --- a/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts +++ b/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts @@ -70,6 +70,7 @@ export interface SavedObjectsType { mappings: SavedObjectsTypeMappingDefinition; /** * An optional map of {@link SavedObjectMigrationFn | migrations} or a function returning a map of {@link SavedObjectMigrationFn | migrations} to be used to migrate the type. + * @deprecated Use {@link SavedObjectsType.modelVersions | modelVersions} instead. */ migrations?: SavedObjectMigrationMap | (() => SavedObjectMigrationMap); /** @@ -78,6 +79,7 @@ export interface SavedObjectsType { * When provided, calls to {@link SavedObjectsClient.create | create} will be validated against this schema. * * See {@link SavedObjectsValidationMap} for more details. + * @deprecated Use {@link SavedObjectsType.modelVersions | modelVersions} instead. */ schemas?: SavedObjectsValidationMap | (() => SavedObjectsValidationMap); /** @@ -177,7 +179,7 @@ export interface SavedObjectsType { modelVersions?: SavedObjectsModelVersionMap | SavedObjectsModelVersionMapProvider; /** - * Allows to opt-in to the new model version API. + * Allows to opt-in to the model version API. * * Must be a valid semver version (with the patch version being necessarily 0) *