diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f86c8ae14fe58..5f7502062abdc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -297,6 +297,7 @@ x-pack/plugins/cross_cluster_replication @elastic/platform-deployment-management packages/kbn-crypto @elastic/kibana-security packages/kbn-crypto-browser @elastic/kibana-core x-pack/plugins/custom_branding @elastic/appex-sharedux +packages/kbn-custom-integrations @elastic/infra-monitoring-ui src/plugins/custom_integrations @elastic/fleet packages/kbn-cypress-config @elastic/kibana-operations x-pack/plugins/dashboard_enhanced @elastic/kibana-presentation @@ -806,6 +807,7 @@ src/plugins/visualizations @elastic/kibana-visualizations x-pack/plugins/watcher @elastic/platform-deployment-management packages/kbn-web-worker-stub @elastic/kibana-operations packages/kbn-whereis-pkg-cli @elastic/kibana-operations +packages/kbn-xstate-utils @elastic/infra-monitoring-ui packages/kbn-yarn-lock-validator @elastic/kibana-operations #### ## Everything below this line overrides the default assignments for each package. diff --git a/.i18nrc.json b/.i18nrc.json index 373f28219a8cb..f5de3b91a9a01 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -18,6 +18,7 @@ "packages/core" ], "customIntegrations": "src/plugins/custom_integrations", + "customIntegrationsPackage": "packages/kbn-custom-integrations", "dashboard": "src/plugins/dashboard", "domDragDrop": "packages/kbn-dom-drag-drop", "controls": "src/plugins/controls", diff --git a/package.json b/package.json index 3e4cb3323cabf..6be8542c598b7 100644 --- a/package.json +++ b/package.json @@ -347,6 +347,7 @@ "@kbn/crypto": "link:packages/kbn-crypto", "@kbn/crypto-browser": "link:packages/kbn-crypto-browser", "@kbn/custom-branding-plugin": "link:x-pack/plugins/custom_branding", + "@kbn/custom-integrations": "link:packages/kbn-custom-integrations", "@kbn/custom-integrations-plugin": "link:src/plugins/custom_integrations", "@kbn/dashboard-enhanced-plugin": "link:x-pack/plugins/dashboard_enhanced", "@kbn/dashboard-plugin": "link:src/plugins/dashboard", @@ -795,6 +796,7 @@ "@kbn/visualization-ui-components": "link:packages/kbn-visualization-ui-components", "@kbn/visualizations-plugin": "link:src/plugins/visualizations", "@kbn/watcher-plugin": "link:x-pack/plugins/watcher", + "@kbn/xstate-utils": "link:packages/kbn-xstate-utils", "@loaders.gl/core": "^3.4.7", "@loaders.gl/json": "^3.4.7", "@loaders.gl/shapefile": "^3.4.7", diff --git a/packages/kbn-custom-integrations/README.md b/packages/kbn-custom-integrations/README.md new file mode 100644 index 0000000000000..229437411a62b --- /dev/null +++ b/packages/kbn-custom-integrations/README.md @@ -0,0 +1,90 @@ +# Custom integrations package + +This package provides UI components and state machines to assist with the creation (and in the future other operations) of custom integrations. For consumers the process *should* be as simple as dropping in the provider and connected components. + +## Basic / quickstart usage + +1. Add provider + +```ts + + + +``` + +2. Include Connected form and button components + +```ts + + ``` + + The form will internally interact with the backing state machines. + +```ts + +``` + +Most props are optional, here for example you may conditionally add an extra set of `isDisabled` conditions. They will be applied on top of the internal state machine conditions that ensure the button is disabled when necessary. TypeScript types can be checked for available options. + +## Initial state + +Initial state is just that, initial state, and isn't "reactive". + +## Provider callbacks + +The provider accepts some callbacks, for example `onIntegrationCreation`. Changes to these references are tracked internally, so feel free to have a callback handler that changes it's identity if needed. + +An example handler: + +```ts +const onIntegrationCreation: OnIntegrationCreationCallback = ( + integrationOptions + ) => { + const { + integrationName: createdIntegrationName, + datasets: createdDatasets, + } = integrationOptions; + + setState((state) => ({ + ...state, + integrationName: createdIntegrationName, + datasetName: createdDatasets[0].name, + lastCreatedIntegrationOptions: integrationOptions, + })); + goToStep('installElasticAgent'); + }; +``` + +## Manual dispatching of events + +Sometimes you may have a flow where it is necessary to manually update the internal state machines and bypass the connected components. This is discouraged, but it is possible for some operations. These events are exposed as `DispatchableEvents`, and these are exposed by the `useConsumerCustomIntegrations()` hook. + +For example `updateCreateFields` will update the fields of the creation form in the same manner as the UI components would. + +These functions will either exist, or be `undefined`, the presence of these functions means that the corresponding state checks against the machine have already passed. For instance, `saveCreateFields()` will only exist (and not be `undefined`) when the creation form is valid. These functions therefore also fulfill the role of condition checking if needed. + +Example usage: + +```ts +const { + dispatchableEvents: { updateCreateFields }, +} = useConsumerCustomIntegrations(); +``` + +## Cleanup + +- For the create flow the machine will try to cleanup a previously created integration if needed (if `options.deletePrevious` is `true`). For example, imagine a wizard flow where someone has navigated forward, then navigates back, makes a change, and saves again, the machine will attempt to delete the previously created integration so that lots of rogue custom integrations aren't left behind. The provider accepts an optional `previouslyCreatedIntegration` prop that can serve as initial state. diff --git a/packages/kbn-custom-integrations/index.ts b/packages/kbn-custom-integrations/index.ts new file mode 100644 index 0000000000000..f25ec944c93a2 --- /dev/null +++ b/packages/kbn-custom-integrations/index.ts @@ -0,0 +1,19 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { + ConnectedCustomIntegrationsForm, + ConnectedCustomIntegrationsButton, +} from './src/components'; +export { useConsumerCustomIntegrations, useCustomIntegrations } from './src/hooks'; +export { CustomIntegrationsProvider } from './src/state_machines'; + +// Types +export type { DispatchableEvents } from './src/hooks'; +export type { Callbacks, InitialState } from './src/state_machines'; +export type { CustomIntegrationOptions } from './src/types'; diff --git a/packages/kbn-custom-integrations/jest.config.js b/packages/kbn-custom-integrations/jest.config.js new file mode 100644 index 0000000000000..ce85d5922ef71 --- /dev/null +++ b/packages/kbn-custom-integrations/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-custom-integrations'], +}; diff --git a/packages/kbn-custom-integrations/kibana.jsonc b/packages/kbn-custom-integrations/kibana.jsonc new file mode 100644 index 0000000000000..61c9067c7e659 --- /dev/null +++ b/packages/kbn-custom-integrations/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/custom-integrations", + "owner": "@elastic/infra-monitoring-ui" +} diff --git a/packages/kbn-custom-integrations/package.json b/packages/kbn-custom-integrations/package.json new file mode 100644 index 0000000000000..80b3bc267e5a6 --- /dev/null +++ b/packages/kbn-custom-integrations/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/custom-integrations", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-custom-integrations/src/components/create/button.tsx b/packages/kbn-custom-integrations/src/components/create/button.tsx new file mode 100644 index 0000000000000..a7bd339d19cc4 --- /dev/null +++ b/packages/kbn-custom-integrations/src/components/create/button.tsx @@ -0,0 +1,91 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useActor, useSelector } from '@xstate/react'; +import React, { useCallback } from 'react'; +import { isSubmittingSelector, isValidSelector } from '../../state_machines/create/selectors'; +import { CreateCustomIntegrationActorRef } from '../../state_machines/create/state_machine'; + +const SUBMITTING_TEXT = i18n.translate('customIntegrationsPackage.create.button.submitting', { + defaultMessage: 'Creating integration...', +}); + +const CONTINUE_TEXT = i18n.translate('customIntegrationsPackage.create.button.continue', { + defaultMessage: 'Continue', +}); + +interface ConnectedCreateCustomIntegrationButtonProps { + machine: CreateCustomIntegrationActorRef; + isDisabled?: boolean; + onClick?: () => void; + submittingText?: string; + continueText?: string; + testSubj: string; +} +export const ConnectedCreateCustomIntegrationButton = ({ + machine, + isDisabled = false, + onClick: consumerOnClick, + submittingText = SUBMITTING_TEXT, + continueText = CONTINUE_TEXT, + testSubj, +}: ConnectedCreateCustomIntegrationButtonProps) => { + const [, send] = useActor(machine); + + const onClick = useCallback(() => { + if (consumerOnClick) { + consumerOnClick(); + } + send({ type: 'SAVE' }); + }, [consumerOnClick, send]); + + const isValid = useSelector(machine, isValidSelector); + const isSubmitting = useSelector(machine, isSubmittingSelector); + + return ( + + ); +}; + +type CreateCustomIntegrationButtonProps = { + isValid: boolean; + isSubmitting: boolean; +} & Omit; + +const CreateCustomIntegrationButton = ({ + onClick, + isValid, + isSubmitting, + isDisabled, + submittingText, + continueText, + testSubj, +}: CreateCustomIntegrationButtonProps) => { + return ( + + {isSubmitting ? submittingText : continueText} + + ); +}; diff --git a/packages/kbn-custom-integrations/src/components/create/error_callout.tsx b/packages/kbn-custom-integrations/src/components/create/error_callout.tsx new file mode 100644 index 0000000000000..032be76aef41c --- /dev/null +++ b/packages/kbn-custom-integrations/src/components/create/error_callout.tsx @@ -0,0 +1,94 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiCallOut } from '@elastic/eui'; +import { + AuthorizationError, + IntegrationError, + IntegrationNotInstalledError, + UnknownError, +} from '../../types'; +import { CreateTestSubjects } from './form'; + +const TITLE = i18n.translate('customIntegrationsPackage.create.errorCallout.title', { + defaultMessage: 'Sorry, there was an error', +}); + +const RETRY_TEXT = i18n.translate('customIntegrationsPackage.create.errorCallout.retryText', { + defaultMessage: 'Retry', +}); + +export const ErrorCallout = ({ + error, + onRetry, + testSubjects, +}: { + error: IntegrationError; + onRetry?: () => void; + testSubjects?: CreateTestSubjects['errorCallout']; +}) => { + if (error instanceof AuthorizationError) { + const authorizationDescription = i18n.translate( + 'customIntegrationsPackage.create.errorCallout.authorization.description', + { + defaultMessage: 'This user does not have permissions to create an integration.', + } + ); + return ( + + ); + } else if (error instanceof UnknownError || error instanceof IntegrationNotInstalledError) { + return ( + + ); + } else { + return null; + } +}; + +const BaseErrorCallout = ({ + message, + onRetry, + testSubjects, +}: { + message: string; + onRetry?: () => void; + testSubjects?: CreateTestSubjects['errorCallout']; +}) => { + return ( + + <> +

{message}

+ {onRetry ? ( + + {RETRY_TEXT} + + ) : null} + +
+ ); +}; diff --git a/packages/kbn-custom-integrations/src/components/create/form.tsx b/packages/kbn-custom-integrations/src/components/create/form.tsx new file mode 100644 index 0000000000000..2273db68a479a --- /dev/null +++ b/packages/kbn-custom-integrations/src/components/create/form.tsx @@ -0,0 +1,236 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIconTip, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useActor, useSelector } from '@xstate/react'; +import { ErrorCallout } from './error_callout'; +import { CreateCustomIntegrationActorRef } from '../../state_machines/create/state_machine'; +import { + CreateCustomIntegrationOptions, + WithOptionalErrors, + WithTouchedFields, +} from '../../state_machines/create/types'; +import { Dataset, IntegrationError } from '../../types'; +import { hasFailedSelector } from '../../state_machines/create/selectors'; + +// NOTE: Hardcoded for now. We will likely extend the functionality here to allow the selection of the type. +// And also to allow adding multiple datasets. +const DATASET_TYPE = 'logs' as const; + +export interface CreateTestSubjects { + integrationName?: string; + datasetName?: string; + errorCallout?: { + callout?: string; + retryButton?: string; + }; +} + +export const ConnectedCreateCustomIntegrationForm = ({ + machineRef, + testSubjects, +}: { + machineRef: CreateCustomIntegrationActorRef; + testSubjects?: CreateTestSubjects; +}) => { + const [state, send] = useActor(machineRef); + const updateIntegrationName = useCallback( + (integrationName: string) => { + send({ type: 'UPDATE_FIELDS', fields: { integrationName } }); + }, + [send] + ); + + const updateDatasetName = useCallback( + (datasetName: string) => { + send({ + type: 'UPDATE_FIELDS', + fields: { + datasets: [{ type: DATASET_TYPE, name: datasetName }], + }, + }); + }, + [send] + ); + + const retry = useCallback(() => { + send({ type: 'RETRY' }); + }, [send]); + + const hasFailed = useSelector(machineRef, hasFailedSelector); + + return ( + + ); +}; + +interface FormProps { + integrationName: CreateCustomIntegrationOptions['integrationName']; + datasetName: Dataset['name']; + errors: WithOptionalErrors['errors']; + touchedFields: WithTouchedFields['touchedFields']; + updateIntegrationName: (integrationName: string) => void; + updateDatasetName: (integrationName: string) => void; + hasFailed: boolean; + onRetry?: () => void; + testSubjects?: CreateTestSubjects; +} + +export const CreateCustomIntegrationForm = ({ + integrationName, + datasetName, + errors, + touchedFields, + updateIntegrationName, + updateDatasetName, + onRetry, + testSubjects, +}: FormProps) => { + return ( + <> + +

+ {i18n.translate('customIntegrationsPackage.create.configureIntegrationDescription', { + defaultMessage: 'Configure integration', + })} +

+
+ + + + + {i18n.translate('customIntegrationsPackage.create.integration.name', { + defaultMessage: 'Integration name', + })} + + + + + + } + helpText={i18n.translate('customIntegrationsPackage.create.integration.helper', { + defaultMessage: + "All lowercase, max 100 chars, special characters will be replaced with '_'.", + })} + isInvalid={hasErrors(errors?.fields?.integrationName) && touchedFields.integrationName} + error={errorsList(errors?.fields?.integrationName)} + > + updateIntegrationName(event.target.value)} + isInvalid={hasErrors(errors?.fields?.integrationName) && touchedFields.integrationName} + max={100} + data-test-subj={ + testSubjects?.integrationName ?? + 'customIntegrationsPackageCreateFormIntegrationNameInput' + } + /> + + + + {i18n.translate('customIntegrationsPackage.create.dataset.name', { + defaultMessage: 'Dataset name', + })} + + + + + + } + helpText={i18n.translate('customIntegrationsPackage.create.dataset.helper', { + defaultMessage: + "All lowercase, max 100 chars, special characters will be replaced with '_'.", + })} + isInvalid={hasErrors(errors?.fields?.datasets?.[0]?.name) && touchedFields.datasets} + error={errorsList(errors?.fields?.datasets?.[0]?.name)} + > + updateDatasetName(event.target.value)} + isInvalid={hasErrors(errors?.fields?.datasets?.[0].name) && touchedFields.datasets} + max={100} + data-test-subj={ + testSubjects?.datasetName ?? 'customIntegrationsPackageCreateFormDatasetNameInput' + } + /> + + + {errors?.general && ( + <> + + + + )} + + ); +}; + +const hasErrors = (errors?: IntegrationError[]) => errors && errors.length > 0; + +const errorsList = (errors?: IntegrationError[]) => { + return hasErrors(errors) ? ( +
    + {errors!.map((error, index) => ( +
  • {error.message}
  • + ))} +
+ ) : null; +}; diff --git a/packages/kbn-custom-integrations/src/components/create/utils.ts b/packages/kbn-custom-integrations/src/components/create/utils.ts new file mode 100644 index 0000000000000..3af857be37236 --- /dev/null +++ b/packages/kbn-custom-integrations/src/components/create/utils.ts @@ -0,0 +1,15 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const replaceSpecialChars = (filename: string) => { + // Replace special characters with _ + const replacedSpecialCharacters = filename.replaceAll(/[^a-zA-Z0-9_]/g, '_'); + // Allow only one _ in a row + const noRepetitions = replacedSpecialCharacters.replaceAll(/[\_]{2,}/g, '_'); + return noRepetitions; +}; diff --git a/packages/kbn-custom-integrations/src/components/custom_integrations_button.tsx b/packages/kbn-custom-integrations/src/components/custom_integrations_button.tsx new file mode 100644 index 0000000000000..7304eefdffa1b --- /dev/null +++ b/packages/kbn-custom-integrations/src/components/custom_integrations_button.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useSelector } from '@xstate/react'; +import { useCustomIntegrations } from '../hooks/use_custom_integrations'; +import { createIsInitializedSelector } from '../state_machines/custom_integrations/selectors'; +import { ConnectedCreateCustomIntegrationButton } from './create/button'; + +interface ConnectedCustomIntegrationsButtonProps { + isDisabled?: boolean; + onClick?: () => void; + testSubj?: string; +} + +export const ConnectedCustomIntegrationsButton = ({ + isDisabled, + onClick, + testSubj = 'customIntegrationsPackageConnectedButton', +}: ConnectedCustomIntegrationsButtonProps) => { + const { customIntegrationsStateService, customIntegrationsState } = useCustomIntegrations(); + + const createIsInitialized = useSelector( + customIntegrationsStateService, + createIsInitializedSelector + ); + + if (createIsInitialized) { + return ( + + ); + } else { + return null; + } +}; diff --git a/packages/kbn-custom-integrations/src/components/custom_integrations_form.tsx b/packages/kbn-custom-integrations/src/components/custom_integrations_form.tsx new file mode 100644 index 0000000000000..a6a3fa30d8593 --- /dev/null +++ b/packages/kbn-custom-integrations/src/components/custom_integrations_form.tsx @@ -0,0 +1,39 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useSelector } from '@xstate/react'; +import { useCustomIntegrations } from '../hooks/use_custom_integrations'; +import { createIsInitializedSelector } from '../state_machines/custom_integrations/selectors'; +import { ConnectedCreateCustomIntegrationForm, CreateTestSubjects } from './create/form'; + +interface Props { + testSubjects?: { + create?: CreateTestSubjects; + }; +} + +export const ConnectedCustomIntegrationsForm = ({ testSubjects }: Props) => { + const { customIntegrationsState, customIntegrationsStateService } = useCustomIntegrations(); + + const createIsInitialized = useSelector( + customIntegrationsStateService, + createIsInitializedSelector + ); + + if (createIsInitialized) { + return ( + + ); + } else { + return null; + } +}; diff --git a/packages/kbn-custom-integrations/src/components/index.ts b/packages/kbn-custom-integrations/src/components/index.ts new file mode 100644 index 0000000000000..165dfb3e202b0 --- /dev/null +++ b/packages/kbn-custom-integrations/src/components/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ConnectedCreateCustomIntegrationForm } from './create/form'; +export * from './create/error_callout'; +export * from './custom_integrations_button'; +export * from './custom_integrations_form'; diff --git a/packages/kbn-custom-integrations/src/hooks/create/use_create_dispatchable_events.ts b/packages/kbn-custom-integrations/src/hooks/create/use_create_dispatchable_events.ts new file mode 100644 index 0000000000000..b17329b7a61b5 --- /dev/null +++ b/packages/kbn-custom-integrations/src/hooks/create/use_create_dispatchable_events.ts @@ -0,0 +1,36 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useActor, useSelector } from '@xstate/react'; +import { useMemo } from 'react'; +import { isUninitializedSelector, isValidSelector } from '../../state_machines/create/selectors'; +import { CreateCustomIntegrationActorRef } from '../../state_machines/create/state_machine'; +import { CreateCustomIntegrationOptions } from '../../state_machines/create/types'; + +export const useCreateDispatchableEvents = ({ + machineRef, +}: { + machineRef: CreateCustomIntegrationActorRef; +}) => { + const [, send] = useActor(machineRef); + const isValid = useSelector(machineRef, isValidSelector); + const isUninitialized = useSelector(machineRef, isUninitializedSelector); + const dispatchableEvents = useMemo(() => { + return { + saveCreateFields: isValid ? () => send({ type: 'SAVE' }) : undefined, + updateCreateFields: !isUninitialized + ? (fields: Partial) => + send({ type: 'UPDATE_FIELDS', fields }) + : undefined, + }; + }, [isUninitialized, isValid, send]); + + return dispatchableEvents; +}; + +export type CreateDispatchableEvents = ReturnType; diff --git a/packages/kbn-custom-integrations/src/hooks/index.ts b/packages/kbn-custom-integrations/src/hooks/index.ts new file mode 100644 index 0000000000000..064d55f63a786 --- /dev/null +++ b/packages/kbn-custom-integrations/src/hooks/index.ts @@ -0,0 +1,11 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { useConsumerCustomIntegrations } from './use_consumer_custom_integrations'; +export { useCustomIntegrations } from './use_custom_integrations'; +export type { DispatchableEvents } from './use_consumer_custom_integrations'; diff --git a/packages/kbn-custom-integrations/src/hooks/use_consumer_custom_integrations.ts b/packages/kbn-custom-integrations/src/hooks/use_consumer_custom_integrations.ts new file mode 100644 index 0000000000000..3d5bb729350c6 --- /dev/null +++ b/packages/kbn-custom-integrations/src/hooks/use_consumer_custom_integrations.ts @@ -0,0 +1,27 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + CreateDispatchableEvents, + useCreateDispatchableEvents, +} from './create/use_create_dispatchable_events'; +import { useCustomIntegrations } from './use_custom_integrations'; + +export const useConsumerCustomIntegrations = () => { + const { customIntegrationsState } = useCustomIntegrations(); + const dispatchableEvents = useCreateDispatchableEvents({ + machineRef: customIntegrationsState.children.createCustomIntegration, + }); + + return { + mode: customIntegrationsState.context.mode, + dispatchableEvents, + }; +}; + +export type DispatchableEvents = CreateDispatchableEvents; diff --git a/packages/kbn-custom-integrations/src/hooks/use_custom_integrations.ts b/packages/kbn-custom-integrations/src/hooks/use_custom_integrations.ts new file mode 100644 index 0000000000000..9ca7a96c72fda --- /dev/null +++ b/packages/kbn-custom-integrations/src/hooks/use_custom_integrations.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useActor } from '@xstate/react'; +import { useCustomIntegrationsContext } from '../state_machines/custom_integrations/provider'; + +export const useCustomIntegrations = () => { + const customIntegrationsStateService = useCustomIntegrationsContext(); + const [customIntegrationsState, customIntegrationsPageSend] = useActor( + customIntegrationsStateService + ); + + return { + customIntegrationsState, + customIntegrationsPageSend, + customIntegrationsStateService, + }; +}; diff --git a/packages/kbn-custom-integrations/src/state_machines/create/defaults.ts b/packages/kbn-custom-integrations/src/state_machines/create/defaults.ts new file mode 100644 index 0000000000000..579f34c6e23f5 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/create/defaults.ts @@ -0,0 +1,29 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const DEFAULT_CONTEXT = { + options: { + deletePrevious: false, + resetOnCreation: true, + errorOnFailedCleanup: false, + }, + fields: { + integrationName: '', + datasets: [ + { + type: 'logs' as const, // NOTE: Hardcoded to logs until we support multiple types via the UI. + name: '', + }, + ], + }, + touchedFields: { + integrationName: false, + datasets: false, + }, + errors: null, +}; diff --git a/packages/kbn-custom-integrations/src/state_machines/create/notifications.ts b/packages/kbn-custom-integrations/src/state_machines/create/notifications.ts new file mode 100644 index 0000000000000..38ef7fb993906 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/create/notifications.ts @@ -0,0 +1,61 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegrationOptions, IntegrationError } from '../../types'; +import { CreateCustomIntegrationContext, CreateCustomIntegrationEvent } from './types'; + +export type CreateCustomIntegrationNotificationEvent = + | { + type: 'INTEGRATION_CREATED'; + fields: CustomIntegrationOptions; + } + | { + type: 'INTEGRATION_CLEANUP'; + integrationName: CustomIntegrationOptions['integrationName']; + } + | { + type: 'INTEGRATION_CLEANUP_FAILED'; + error: IntegrationError; + } + | { + type: 'CREATE_INITIALIZED'; + }; + +export const CreateIntegrationNotificationEventSelectors = { + integrationCreated: (context: CreateCustomIntegrationContext) => + ({ + type: 'INTEGRATION_CREATED', + fields: context.fields, + } as CreateCustomIntegrationNotificationEvent), + integrationCleanup: ( + context: CreateCustomIntegrationContext, + event: CreateCustomIntegrationEvent + ) => { + return 'data' in event && 'integrationName' in event.data + ? ({ + type: 'INTEGRATION_CLEANUP', + integrationName: event.data.integrationName, + } as CreateCustomIntegrationNotificationEvent) + : null; + }, + integrationCleanupFailed: ( + context: CreateCustomIntegrationContext, + event: CreateCustomIntegrationEvent + ) => { + return 'data' in event && event.data instanceof IntegrationError + ? ({ + type: 'INTEGRATION_CLEANUP_FAILED', + error: event.data, + } as CreateCustomIntegrationNotificationEvent) + : null; + }, + initialized: () => + ({ + type: 'CREATE_INITIALIZED', + } as CreateCustomIntegrationNotificationEvent), +}; diff --git a/packages/kbn-custom-integrations/src/state_machines/create/selectors.ts b/packages/kbn-custom-integrations/src/state_machines/create/selectors.ts new file mode 100644 index 0000000000000..cc5c26f1ca39f --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/create/selectors.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CreateCustomIntegrationState } from './state_machine'; + +export const isValidSelector = (state: CreateCustomIntegrationState) => + state && state.matches('valid'); + +export const isSubmittingSelector = (state: CreateCustomIntegrationState) => + state && state.matches('submitting'); + +export const isUninitializedSelector = (state: CreateCustomIntegrationState) => + !state || state.matches('uninitialized'); + +export const hasFailedSelector = (state: CreateCustomIntegrationState) => + state && state.matches('failure'); diff --git a/packages/kbn-custom-integrations/src/state_machines/create/state_machine.ts b/packages/kbn-custom-integrations/src/state_machines/create/state_machine.ts new file mode 100644 index 0000000000000..ad93d3eb8cb05 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/create/state_machine.ts @@ -0,0 +1,350 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { actions, ActorRefFrom, createMachine, EmittedFrom, SpecialTargets } from 'xstate'; +import deepEqual from 'react-fast-compare'; +import { sendIfDefined, OmitDeprecatedState } from '@kbn/xstate-utils'; +import { IntegrationError, NamingCollisionError } from '../../types'; +import { IIntegrationsClient } from '../services/integrations_client'; +import { + createArrayValidator, + createCharacterLimitValidation, + createIsEmptyValidation, + createIsLowerCaseValidation, + initializeValidateFields, +} from '../services/validation'; +import { DEFAULT_CONTEXT } from './defaults'; +import { CreateIntegrationNotificationEventSelectors } from './notifications'; +import { + CreateCustomIntegrationContext, + CreateCustomIntegrationEvent, + CreateCustomIntegrationTypestate, + DefaultCreateCustomIntegrationContext, + WithErrors, + WithPreviouslyCreatedIntegration, + WithTouchedFields, + WithFields, +} from './types'; +import { replaceSpecialChars } from '../../components/create/utils'; + +export const createPureCreateCustomIntegrationStateMachine = ( + initialContext: DefaultCreateCustomIntegrationContext = DEFAULT_CONTEXT +) => + createMachine< + CreateCustomIntegrationContext, + CreateCustomIntegrationEvent, + CreateCustomIntegrationTypestate + >( + { + /** @xstate-layout N4IgpgJg5mDOIC5QGEBOYCGAXMyCusWA9gLYCSAdjlKtgJZEUDEAqgAoAiAggCoCiAfQBiZPgBkOAZQDaABgC6iUAAcisOlgYUlIAB6IALACYANCACeh2UYB0ATgBsD6w4DsAVgCMngMyvPAL4BZmiYOPiEpJTUtJqMNngUdEmaGAA2dABekExyikggqupx2gX6CEYGABw2PkZVnk6yjVXVBg5mlgju7TYGxu4eBo1O1UEh6Ni4BMTkVGA09PGJyRp06Vk50p75KmprjDrllTV1DU0tbR0WiK5GPjauHnaydz4G763jIKFTEbPRBaxLQ2ABuGwg9AoUCYEEYYBsyVBRAA1gjfuEZlF5osSmCIVCoAgkUQAMZLCh5PI6IoHUqgcquHyeewOKquOyeKp2Iyedx2HydRA9Aw2Kr8oyybk+HyDdxGb4Y6aROYxCn4jKQzTQphgVCoIioGzKNLYABmhpINiV-2xarx4M1hOJFGR5JKVIUNP2JSOt2ZrPZnO5vP5gpuCH6shsviMDiMdgMnM8zXcismmJVgNxIMddAgTEkXAAanxqQVab6yohvJ47DZeeyOVK+T5ZNcumGxfy65Lk1UHOmwsqATjgfE8wWi6XtrtCj6tH6ELX642ni8Gu42x3EG3o347O4jwLPEZ5YPgj8MyO7UD1ZPCyWy0Y55XF9Xl95V1z1y2t+2hW6D5aglA8j1kAwtyHP4sVVO88QgMA0jAbUoDYdBQQYAhYXhRFXVRdFr1tOCc3iRDkNQ9CwEwogCBdN0KU9V8F0OD9Gl6M46zDDlBgMQD6lFVxrDsET+QcFMmQvCZh2I7NxwoGxyJQ5I0IwrDYF1fVDWNU0sAtVArRtWC5PVJTKLU2jYHoslGIUcs9mKd8GRrBwOPqLiBR41w+IjBo+hExwHHceM+XaVxoMzUd7RBMyVKomjsL1A0jRNc1LWtIjjLHUykOU6F4vU6z3S0Kkdm9RzWOc5dXIcWp3MPTzD28-i-AbYT2WGIS4w8CKbxI+SbFgPAACMSA0VCcIoBESTRDKZKy6L4iG0bxpUorbPkez5wq+k9EQRt6waM8DCZZlIP6QCfCqaM+XE1420cbw00vIys2yvFlrGrAJqS7TUr09LXqi+CQU+1boXWj07K9CsWN244uUO08elOzxzp8rp6lcNq2xOs8grscVnukmC3sWhShtJUk4A0ra30qvaKiedwbC3U8uQTQnxUA9w-OsXnzyMfxgvCl7MrJkH4jNDA6DSPB0CYAAlPgeEVgBNOm4aXIxeVFMMGlkHxXLqDxAJOh4Ux5eM2SN9sB162T3pBaXZflsAlZV9XZ3KultcqWwejZZtxXaOwWtqoSHuZSVIKqB2FslhT0FgFCJs1nal2j+sDHbIWeU5V4eeuhsPAHfw-G5TxRcvCgiEQ+ACiB29SN2+n4cQABaHcEC7mxZH7gfB4H+p44lluEiSFJ1gybIIB9qsquMQC2VFIXecTbzeQk0fgfHydCXnpzGa8FnPK3PxvE+bv3HbGNZB5Pwt0gqU7B35uBsSYg8FJAALSBD4ZuUOolRWb9w5j4Dc1RXCAX8IJZ41hrpOHuKLEmkV373ghAA9uCAeSnyDomKocYBTvB5pKECwZjDtEaImN+-UMFOhKEIGWyE56wwzh+XBgYOTVCITKDGhhxLdgfs0FMCDX5i3mmPAasV8oWQIFgpcaMvC1A+NwpMDhEzuH4k8WoPE7BDBlPyV4tCTIfRGl9VCCiPyShAUyfoR5eZxmaN3eoth86BxvuJdeJinZLW-tTWADcHK+2sUmGorxCGyBvoTdo18jyPFLpQm+1ReY+PJjYF2ct0BWKqpUQmfdXCROia0JwZtnB9wlGA8U+iqhxwkaTXeA1k6pxUjkxmCYAz3A+I9L8HwebxOZq0SoyTxR1KCEAA */ + context: initialContext, + preserveActionOrder: true, + predictableActionArguments: true, + id: 'CreateCustomIntegration', + initial: 'uninitialized', + on: { + UPDATE_FIELDS: { + target: '#validating', + actions: 'storeFields', + }, + }, + states: { + uninitialized: { + always: [ + { + target: 'validating', + cond: 'shouldValidateInitialContext', + }, + { + target: 'untouched', + }, + ], + exit: ['notifyInitialized'], + }, + validating: { + id: 'validating', + invoke: { + src: 'validateFields', + onDone: { + target: 'valid', + }, + onError: { + target: 'validationFailed', + actions: ['storeClientErrors'], + }, + }, + }, + untouched: {}, + valid: { + id: 'valid', + entry: ['clearErrors'], + on: { + SAVE: [ + { + target: 'success', + cond: 'fieldsMatchPreviouslyCreated', + }, + { + target: 'deletingPrevious', + cond: 'shouldCleanup', + }, + { + target: '#submitting', + }, + ], + }, + }, + validationFailed: { + id: 'validationFailed', + }, + deletingPrevious: { + invoke: { + src: 'cleanup', + onDone: { + target: '#submitting', + actions: ['clearPreviouslyCreatedIntegration', 'notifyIntegrationCleanup'], + }, + onError: [ + { + target: '#failure', + cond: 'shouldErrorOnFailedCleanup', + actions: ['storeServerErrors', 'notifyIntegrationCleanupFailed'], + }, + { + target: '#submitting', + }, + ], + }, + }, + submitting: { + id: 'submitting', + invoke: { + src: 'save', + onDone: { + target: 'success', + actions: ['storePreviouslyCreatedIntegration'], + }, + onError: { + target: 'failure', + actions: ['storeServerErrors'], + }, + }, + }, + success: { + entry: ['notifyIntegrationCreated'], + always: [ + { + target: 'resetting', + cond: 'shouldReset', + }, + ], + }, + failure: { + id: 'failure', + on: { + RETRY: [ + { + target: 'deletingPrevious', + cond: 'shouldCleanup', + }, + { + target: 'submitting', + }, + ], + }, + }, + resetting: { + entry: ['resetValues'], + always: { + target: 'untouched', + }, + }, + }, + }, + { + actions: { + storeClientErrors: actions.assign((context, event) => { + return 'data' in event && 'errors' in event.data + ? ({ + errors: { + fields: event.data.errors, + general: null, + }, + } as WithErrors) + : {}; + }), + storeServerErrors: actions.assign((context, event) => { + return 'data' in event && event.data instanceof IntegrationError + ? ({ + errors: { + ...(event.data instanceof NamingCollisionError + ? { fields: { integrationName: [event.data] } } + : { fields: {} }), + ...(!(event.data instanceof NamingCollisionError) + ? { general: event.data } + : { general: null }), + }, + } as WithErrors) + : {}; + }), + clearErrors: actions.assign((context, event) => { + return { errors: null }; + }), + storePreviouslyCreatedIntegration: actions.assign((context, event) => { + return 'data' in event && !(event.data instanceof IntegrationError) + ? ({ + previouslyCreatedIntegration: context.fields, + } as WithPreviouslyCreatedIntegration) + : {}; + }), + clearPreviouslyCreatedIntegration: actions.assign((context, event) => { + return 'data' in event && 'previouslyCreatedIntegration' in context + ? ({ + previouslyCreatedIntegration: undefined, + } as WithPreviouslyCreatedIntegration) + : {}; + }), + storeFields: actions.assign((context, event) => { + return event.type === 'UPDATE_FIELDS' + ? ({ + fields: { + ...context.fields, + ...event.fields, + integrationName: + event.fields.integrationName !== undefined + ? replaceSpecialChars(event.fields.integrationName) + : context.fields.integrationName, + datasets: + event.fields.datasets !== undefined + ? event.fields.datasets.map((dataset) => ({ + ...dataset, + name: replaceSpecialChars(dataset.name), + })) + : context.fields.datasets, + }, + touchedFields: { + ...context.touchedFields, + ...Object.keys(event.fields).reduce( + (acc, field) => ({ ...acc, [field]: true }), + {} as WithTouchedFields['touchedFields'] + ), + }, + } as WithFields & WithTouchedFields) + : {}; + }), + resetValues: actions.assign((context, event) => { + return { + fields: DEFAULT_CONTEXT.fields, + touchedFields: DEFAULT_CONTEXT.touchedFields, + errors: null, + }; + }), + }, + guards: { + shouldValidateInitialContext: (context) => + !deepEqual(DEFAULT_CONTEXT.fields, context.fields), + fieldsMatchPreviouslyCreated: (context) => + deepEqual(context.fields, context.previouslyCreatedIntegration), + shouldCleanup: (context) => + context.options.deletePrevious === true && + context.previouslyCreatedIntegration !== undefined, + shouldErrorOnFailedCleanup: (context) => context.options.errorOnFailedCleanup === true, + shouldReset: (context) => context.options.resetOnCreation === true, + }, + } + ); + +export interface CreateCustomIntegrationStateMachineDependencies { + initialContext?: DefaultCreateCustomIntegrationContext; + integrationsClient: IIntegrationsClient; +} + +export const createCreateCustomIntegrationStateMachine = ({ + initialContext, + integrationsClient, +}: CreateCustomIntegrationStateMachineDependencies) => { + return createPureCreateCustomIntegrationStateMachine(initialContext).withConfig({ + services: { + validateFields: initializeValidateFields({ + integrationName: [ + createIsEmptyValidation( + i18n.translate('customIntegrationsPackage.validations.integrationName.requiredError', { + defaultMessage: 'An integration name is required.', + }) + ), + createIsLowerCaseValidation( + i18n.translate('customIntegrationsPackage.validations.integrationName.lowerCaseError', { + defaultMessage: 'An integration name should be lowercase.', + }) + ), + createCharacterLimitValidation( + i18n.translate( + 'customIntegrationsPackage.validations.integrationName.characterLimitError', + { + defaultMessage: 'An integration name should be less than 100 characters.', + } + ), + 100 + ), + ], + datasets: createArrayValidator({ + name: [ + createIsEmptyValidation( + i18n.translate('customIntegrationsPackage.validations.datasets.requiredError', { + defaultMessage: 'A dataset name is required.', + }) + ), + createIsLowerCaseValidation( + i18n.translate('customIntegrationsPackage.validations.datasets.lowerCaseError', { + defaultMessage: 'A dataset name should be lowercase.', + }) + ), + createCharacterLimitValidation( + i18n.translate('customIntegrationsPackage.validations.datasets.characterLimitError', { + defaultMessage: 'A dataset name should be less than 100 characters.', + }), + 100 + ), + ], + }), + }), + save: (context) => { + return integrationsClient.createCustomIntegration(context.fields); + }, + cleanup: (context) => { + return integrationsClient.deleteCustomIntegration({ + integrationName: context.previouslyCreatedIntegration!.integrationName, // Value will be set due to the guard. + version: '1.0.0', + }); + }, + }, + actions: { + notifyIntegrationCreated: sendIfDefined(SpecialTargets.Parent)( + CreateIntegrationNotificationEventSelectors.integrationCreated + ), + notifyIntegrationCleanup: sendIfDefined(SpecialTargets.Parent)( + CreateIntegrationNotificationEventSelectors.integrationCleanup + ), + notifyIntegrationCleanupFailed: sendIfDefined(SpecialTargets.Parent)( + CreateIntegrationNotificationEventSelectors.integrationCleanupFailed + ), + notifyInitialized: sendIfDefined(SpecialTargets.Parent)( + CreateIntegrationNotificationEventSelectors.initialized + ), + }, + }); +}; + +export type CreateCustomIntegrationStateMachine = ReturnType< + typeof createPureCreateCustomIntegrationStateMachine +>; +export type CreateCustomIntegrationActorRef = OmitDeprecatedState< + ActorRefFrom +>; +export type CreateCustomIntegrationState = EmittedFrom; diff --git a/packages/kbn-custom-integrations/src/state_machines/create/types.ts b/packages/kbn-custom-integrations/src/state_machines/create/types.ts new file mode 100644 index 0000000000000..e7cfad728d78e --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/create/types.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegrationOptions, IntegrationError } from '../../types'; +import { + CreateCustomIntegrationValue, + DeleteCustomIntegrationResponse, +} from '../services/integrations_client'; +import { IndexedValidationErrors, ValidationErrors } from '../services/validation'; + +export type CreateCustomIntegrationOptions = CustomIntegrationOptions; + +export interface WithTouchedFields { + touchedFields: Record; +} + +export type CreateInitialState = WithOptions & WithFields & WithPreviouslyCreatedIntegration; + +export interface WithOptions { + options: { + deletePrevious?: boolean; + resetOnCreation?: boolean; + errorOnFailedCleanup?: boolean; + }; +} + +export interface WithIntegrationName { + integrationName: CreateCustomIntegrationOptions['integrationName']; +} + +export interface WithPreviouslyCreatedIntegration { + previouslyCreatedIntegration?: CreateCustomIntegrationOptions; +} + +export interface WithDatasets { + datasets: CreateCustomIntegrationOptions['datasets']; +} + +export interface WithFields { + fields: WithIntegrationName & WithDatasets; +} + +export interface WithErrors { + errors: { + fields: Partial<{ + integrationName: IntegrationError[]; + datasets: IndexedValidationErrors; + }> | null; + general: IntegrationError | null; + }; +} + +export interface WithNullishErrors { + errors: null; +} + +export type WithOptionalErrors = WithErrors | WithNullishErrors; + +export type DefaultCreateCustomIntegrationContext = WithOptions & + WithFields & + WithTouchedFields & + WithPreviouslyCreatedIntegration & + WithNullishErrors; + +export type CreateCustomIntegrationTypestate = + | { + value: 'uninitialized'; + context: DefaultCreateCustomIntegrationContext; + } + | { + value: 'validating'; + context: DefaultCreateCustomIntegrationContext & WithOptionalErrors; + } + | { value: 'valid'; context: DefaultCreateCustomIntegrationContext & WithNullishErrors } + | { + value: 'validationFailed'; + context: DefaultCreateCustomIntegrationContext & WithErrors; + } + | { value: 'submitting'; context: DefaultCreateCustomIntegrationContext & WithNullishErrors } + | { value: 'success'; context: DefaultCreateCustomIntegrationContext & WithNullishErrors } + | { value: 'failure'; context: DefaultCreateCustomIntegrationContext & WithErrors } + | { + value: 'deletingPrevious'; + context: DefaultCreateCustomIntegrationContext & WithNullishErrors; + }; + +export type CreateCustomIntegrationContext = CreateCustomIntegrationTypestate['context']; + +export type CreateCustomIntegrationEvent = + | { + type: 'UPDATE_FIELDS'; + fields: Partial; + } + | { + type: 'INITIALIZE'; + } + | { + type: 'SAVE'; + } + | { + type: 'RETRY'; + } + // NOTE: These aren't ideal but they're more helpful than the DoneInvokeEvent<> and ErrorPlatformEvent types + | { + type: 'error.platform.validating:invocation[0]'; + data: { errors: ValidationErrors }; + } + | { + type: 'error.platform.submitting:invocation[0]'; + data: IntegrationError; + } + | { + type: 'done.invoke.submitting:invocation[0]'; + data: CreateCustomIntegrationValue; + } + | { + type: 'done.invoke.CreateCustomIntegration.deletingPrevious:invocation[0]'; + data: DeleteCustomIntegrationResponse; + } + | { + type: 'error.platform.CreateCustomIntegration.deletingPrevious:invocation[0]'; + data: IntegrationError; + }; diff --git a/packages/kbn-custom-integrations/src/state_machines/custom_integrations/defaults.ts b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/defaults.ts new file mode 100644 index 0000000000000..0c1d58a9ba055 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/defaults.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DefaultCustomIntegrationsContext } from './types'; + +export const DEFAULT_CONTEXT: DefaultCustomIntegrationsContext = { + mode: 'create' as const, +}; diff --git a/packages/kbn-custom-integrations/src/state_machines/custom_integrations/notifications.ts b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/notifications.ts new file mode 100644 index 0000000000000..96385b9bc4ce5 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/notifications.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createNotificationChannel, NotificationChannel } from '@kbn/xstate-utils'; +import { CreateCustomIntegrationNotificationEvent } from '../create/notifications'; +import { CustomIntegrationsContext, CustomIntegrationsEvent } from './types'; + +export type CustomIntegrationsNotificationChannel = NotificationChannel< + CustomIntegrationsContext, + CustomIntegrationsEvent | CreateCustomIntegrationNotificationEvent, + CustomIntegrationsEvent | CreateCustomIntegrationNotificationEvent +>; + +export const createCustomIntegrationsNotificationChannel = () => { + return createNotificationChannel< + CustomIntegrationsContext, + CustomIntegrationsEvent | CreateCustomIntegrationNotificationEvent, + CustomIntegrationsEvent | CreateCustomIntegrationNotificationEvent + >(false); +}; diff --git a/packages/kbn-custom-integrations/src/state_machines/custom_integrations/provider.tsx b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/provider.tsx new file mode 100644 index 0000000000000..0f46f7e064b5d --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/provider.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useInterpret } from '@xstate/react'; +import createContainer from 'constate'; +import type { HttpSetup } from '@kbn/core-http-browser'; +import { useEffect, useState } from 'react'; +import { isDevMode } from '@kbn/xstate-utils'; +import { createCustomIntegrationsStateMachine } from './state_machine'; +import { IntegrationsClient } from '../services/integrations_client'; +import { CustomIntegrationOptions, IntegrationError } from '../../types'; +import { InitialState } from './types'; +import { createCustomIntegrationsNotificationChannel } from './notifications'; + +interface Services { + http: HttpSetup | undefined; +} + +export interface Callbacks { + onIntegrationCreation?: (integrationOptions: CustomIntegrationOptions) => void; + onIntegrationCleanup?: (integrationName: CustomIntegrationOptions['integrationName']) => void; + onIntegrationCleanupFailed?: (error: IntegrationError) => void; +} + +type ProviderProps = { + services: Services; + useDevTools?: boolean; + initialState: InitialState; +} & Callbacks; + +export const useCustomIntegrationsState = ({ + services, + useDevTools = isDevMode(), + onIntegrationCreation, + onIntegrationCleanup, + onIntegrationCleanupFailed, + initialState, +}: ProviderProps) => { + const { http } = services; + + if (!http) + throw new Error( + 'Please ensure the HTTP service from Core is provided to the useCustomIntegrations Provider' + ); + + const [integrationsClient] = useState(() => new IntegrationsClient(http)); + const [customIntegrationsNotificationsChannel] = useState(() => + createCustomIntegrationsNotificationChannel() + ); + const [notificationsService] = useState(() => + customIntegrationsNotificationsChannel.createService() + ); + + // Provide notifications outside of the state machine context + useEffect(() => { + const sub = notificationsService.subscribe((event) => { + if (event.type === 'INTEGRATION_CREATED' && onIntegrationCreation) { + onIntegrationCreation(event.fields); + } else if (event.type === 'INTEGRATION_CLEANUP' && onIntegrationCleanup) { + onIntegrationCleanup(event.integrationName); + } else if (event.type === 'INTEGRATION_CLEANUP_FAILED' && onIntegrationCleanupFailed) { + onIntegrationCleanupFailed(event.error); + } + }); + return () => sub.unsubscribe(); + }, [ + notificationsService, + onIntegrationCleanup, + onIntegrationCleanupFailed, + onIntegrationCreation, + ]); + + const customIntegrationsStateService = useInterpret( + () => + createCustomIntegrationsStateMachine({ + integrationsClient, + customIntegrationsNotificationsChannel, + initialState, + }), + { devTools: useDevTools } + ); + return customIntegrationsStateService; +}; + +export const [CustomIntegrationsProvider, useCustomIntegrationsContext] = createContainer( + useCustomIntegrationsState +); diff --git a/packages/kbn-custom-integrations/src/state_machines/custom_integrations/selectors.ts b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/selectors.ts new file mode 100644 index 0000000000000..bbe65a650fc29 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/selectors.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegrationsState } from './state_machine'; + +export const createIsInitializedSelector = (state: CustomIntegrationsState) => + state && state.matches({ create: 'initialized' }); diff --git a/packages/kbn-custom-integrations/src/state_machines/custom_integrations/state_machine.ts b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/state_machine.ts new file mode 100644 index 0000000000000..4bd71313bf43a --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/state_machine.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ActorRefFrom, createMachine, EmittedFrom } from 'xstate'; +import { OmitDeprecatedState } from '@kbn/xstate-utils'; +import { DEFAULT_CONTEXT } from './defaults'; +import { DEFAULT_CONTEXT as DEFAULT_CREATE_CONTEXT } from '../create/defaults'; +import { + CustomIntegrationsContext, + CustomIntegrationsEvent, + CustomIntegrationsTypestate, + DefaultCustomIntegrationsContext, + InitialState, +} from './types'; +import { createCreateCustomIntegrationStateMachine } from '../create/state_machine'; +import { IIntegrationsClient } from '../services/integrations_client'; +import { CustomIntegrationsNotificationChannel } from './notifications'; + +export const createPureCustomIntegrationsStateMachine = ( + initialContext: DefaultCustomIntegrationsContext = DEFAULT_CONTEXT +) => + createMachine( + { + /** @xstate-layout N4IgpgJg5mDOIC5QEkB2AXMUBOBDdAlgPaqwB0ArqgdYbgDYEBekAxANoAMAuoqAA5FYBQiT4gAHogC0ATgBs8sgEYAHAHYALMs7rV85ctkAmADQgAnonWcysgKwHZmgMwPlN1aoC+382kwcfGJSMnoiXAgaKFYIEjAyGgA3IgBrBICsPFFQ8MjohGSiAGNgki5uCvFBYRzxKQRpdQ8yY3tlRTVOF05OTXMrBGUXJXtZVU1J1UMXTWNffwwsstyIqNQYsGxsImwyfnp8ADNdgFsyTKCc8jz1qELUFNKciqqkEBqREPqZHWNjMj2GzGTiqWQuYyuYyqAaIaFKVScZzydT2EY6FwuBYgS7ZEI3NaQRIQehgVgAGQA8gBBAAiAH0ALKUgBKAFF6cgAHIAFTZAHEWdSechKVyAMpvARCL5id4NaTGHqApyyDzKYxaDywhA9VRkMGycGcZSuPSg7G4lYEyJEggksnitnUlkAYQAEpzeQKhSKxZKeNUZXV5TIXOpZGRNA5-prUUigTq2uoyLNOGjFJoI7N5n4cUsrviwoSIMTSaxxayeV6+YLhaKJVKPsHvqHGs1bBrFJj7JN7I4zJZEJolOpjApZoZ5FD9JaC3iSDaIHaHRXnW7Pdza76G+L6eKeezqYyA7x3p8Q6AFSCEUZe2jVD1ZEidUZNGQI2iO7IbA5ZHPAgXVZbVLe1y0rFlqy3H1639fdD2dE8mwvVsrzDMYozRVx7FUTUxiRdQdWkQwAUMbQRkhZ8XCBTQAOWa5ixAxi7kZXYyTiVAEiKdILnna1mKJW5olY7AwAeJ4VleQNzxbOU0IQLN3wotxphsFFDCI2YDXsOM0Q1I1NVovMrQY25BLWYS2NYLYdj2A5jjOXjAP4szSyEjYRLEopnhCKSz2lWpUMkX4TXfdN-lw0E+kUQihwQHD9W6XRsOmEdXDowtFwE0tlwAIyIKhijAcUwFwbBigAC2iEz8TXF0PRrGC-UbaSAtlVAfkaexbAhdRUQmU1+yBLQdTmWx0xwmxPFmeRc0WZzTJLMg8oK1AipKsrKuqvjrlYCRYHQfAElwI5MGwAAKHpOAASlYGqstc5awHywritK8qqo2e7SGQ2SOrbaRpxTVR2jGXDUXBPRRuMZRAX7EH1HkBR1BcU0jPm+ii0elbXo2j7toW-FxXQUTcFOWA6o3Rq62avcDyPJDWubQK5OC+Leg-VHDA8MdXDUeQdQhSNUY7cdmkxTg5vzQmHqWnG1rezbPqgb7YGJ0nyb2g6jrIE6zsu3pbtV7KnpehW8a2r6dqJknSvJ36Wf++Sug-BR2mMeRerBadRv1f4FDVT3wV69Hpcx2X8g2AAxXACFJCBWHZKk6Wpnd-Qd9rOuIzVYemadOBREHoxRpMQdTJEzRBTUwXUXw81QIhl3gd5VaDR2s+GIwDQ6TQC70XsfxcIjoRTMiOg1XCDAjUPjaoGgvgYZhIDbzOAYMsh0xHboNF7Nwh7i4j7FTMYPcfftZhRtEMqApdohXy82eI7pR2jExEb1D3YsGDpbD6xFnH7DoUE4Zr4uRLPfIKCo5gpmjDpf4fVuoOC-r8I0ZADDaFmvoHQyh2hYmMtbCOy5QIOggaza8XhATTFwtMGMIM2g6iRphWayU+4I3sKAxaTF3JQE8qQp2bMXYozcECHsU5lClyPm4I0mh8LdC0FLY22NnqrXWu9S2KsCGkD4VncMsNwxImfkiDQfVBYKDhglac05wQKA4VjOWyjcZqOVqrdWdtm5tQfg0DUAI5jB17moOY44BZxXVK0Bw1EcH6D5v+fBMtgJ3BjnHZeMl24A2wZGHQ4If5omrsg3UiNWh9RjPpaM4467eCAA */ + context: initialContext, + preserveActionOrder: true, + predictableActionArguments: true, + id: 'CustomIntegrations', + initial: 'uninitialized', + states: { + uninitialized: { + always: 'create', + }, + create: { + invoke: [ + { + id: 'createCustomIntegration', + src: 'createCustomIntegration', + }, + ], + on: { + CREATE_INITIALIZED: { + target: '.initialized', + }, + }, + states: { + initialized: { + meta: { + _DX_warning_: + "These inner initialized states on the top level machine exist primarily so that 'connected' components can block the reading of the state of child machines whilst undefined on the first render cycle", + }, + on: { + INTEGRATION_CREATED: { + actions: ['notifyIntegrationCreated'], + }, + INTEGRATION_CLEANUP: { + actions: ['notifyIntegrationCleanup'], + }, + INTEGRATION_CLEANUP_FAILED: { + actions: ['notifyIntegrationCleanupFailed'], + }, + }, + }, + }, + }, + update: { + // NOTE: Placeholder for the future addition of "Add dataset to existing custom integration" + }, + }, + }, + { + actions: {}, + guards: {}, + } + ); + +export interface CustomIntegrationsStateMachineDependencies { + initialContext?: DefaultCustomIntegrationsContext; + integrationsClient: IIntegrationsClient; + customIntegrationsNotificationsChannel: CustomIntegrationsNotificationChannel; + initialState: InitialState; +} + +export const createCustomIntegrationsStateMachine = ({ + initialContext, + integrationsClient, + customIntegrationsNotificationsChannel, + initialState, +}: CustomIntegrationsStateMachineDependencies) => { + return createPureCustomIntegrationsStateMachine(initialContext).withConfig({ + services: { + createCustomIntegration: (context) => { + return createCreateCustomIntegrationStateMachine({ + integrationsClient, + initialContext: + initialState.mode === 'create' + ? { + ...DEFAULT_CREATE_CONTEXT, + ...(initialState?.context ? initialState?.context : {}), + options: { + ...DEFAULT_CREATE_CONTEXT.options, + ...(initialState?.context?.options ? initialState.context.options : {}), + }, + fields: { + ...DEFAULT_CREATE_CONTEXT.fields, + ...(initialState?.context?.fields ? initialState.context.fields : {}), + }, + } + : DEFAULT_CREATE_CONTEXT, + }); + }, + }, + actions: { + notifyIntegrationCreated: customIntegrationsNotificationsChannel.notify((context, event) => { + return event; + }), + notifyIntegrationCleanup: customIntegrationsNotificationsChannel.notify((context, event) => { + return event; + }), + notifyIntegrationCleanupFailed: customIntegrationsNotificationsChannel.notify( + (context, event) => { + return event; + } + ), + }, + }); +}; + +export type CustomIntegrationsStateMachine = ReturnType< + typeof createPureCustomIntegrationsStateMachine +>; +export type CustomIntegrationsActorRef = OmitDeprecatedState< + ActorRefFrom +>; +export type CustomIntegrationsState = EmittedFrom; diff --git a/packages/kbn-custom-integrations/src/state_machines/custom_integrations/types.ts b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/types.ts new file mode 100644 index 0000000000000..6154aa8a14399 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/custom_integrations/types.ts @@ -0,0 +1,35 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CreateCustomIntegrationNotificationEvent } from '../create/notifications'; +import { CreateInitialState } from '../create/types'; + +type ChildInitialStates = Partial; +export type InitialState = { context?: ChildInitialStates } & WithSelectedMode; + +export interface WithSelectedMode { + mode: Mode; +} + +export type Mode = 'create' | 'update'; + +export type DefaultCustomIntegrationsContext = WithSelectedMode; + +export type CustomIntegrationsTypestate = + | { + value: 'uninitialized'; + context: DefaultCustomIntegrationsContext; + } + | { + value: 'create' | { create: 'initialized' }; + context: DefaultCustomIntegrationsContext; + }; + +export type CustomIntegrationsContext = CustomIntegrationsTypestate['context']; + +export type CustomIntegrationsEvent = CreateCustomIntegrationNotificationEvent; diff --git a/packages/kbn-custom-integrations/src/state_machines/index.ts b/packages/kbn-custom-integrations/src/state_machines/index.ts new file mode 100644 index 0000000000000..4f1de9bb58455 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/index.ts @@ -0,0 +1,11 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { CustomIntegrationsProvider } from './custom_integrations/provider'; +export type { Callbacks } from './custom_integrations/provider'; +export type { InitialState } from './custom_integrations/types'; diff --git a/packages/kbn-custom-integrations/src/state_machines/services/integrations_client.ts b/packages/kbn-custom-integrations/src/state_machines/services/integrations_client.ts new file mode 100644 index 0000000000000..c5606a7e2e154 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/services/integrations_client.ts @@ -0,0 +1,150 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { HttpSetup } from '@kbn/core/public'; +import { EPM_API_ROUTES } from '@kbn/fleet-plugin/common'; +import * as rt from 'io-ts'; +import { i18n } from '@kbn/i18n'; +import { decodeOrThrow } from '@kbn/io-ts-utils'; +import { + AuthorizationError, + customIntegrationOptionsRT, + DecodeError, + integrationNameRT, + IntegrationNotInstalledError, + NamingCollisionError, + UnknownError, +} from '../../types'; + +const GENERIC_CREATE_ERROR_MESSAGE = i18n.translate( + 'customIntegrationsPackage.genericCreateError', + { + defaultMessage: 'Unable to create an integration', + } +); + +const GENERIC_DELETE_ERROR_MESSAGE = i18n.translate( + 'customIntegrationsPackage.genericDeleteError', + { + defaultMessage: 'Unable to delete integration', + } +); + +/** + * Constants + */ +const CUSTOM_INTEGRATIONS_URL = EPM_API_ROUTES.CUSTOM_INTEGRATIONS_PATTERN; +const DELETE_PACKAGE_URL = EPM_API_ROUTES.DELETE_PATTERN; + +export interface IIntegrationsClient { + createCustomIntegration( + params?: CreateCustomIntegrationRequestQuery + ): Promise; + deleteCustomIntegration( + params?: DeleteCustomIntegrationRequestQuery + ): Promise; +} + +export class IntegrationsClient implements IIntegrationsClient { + constructor(private readonly http: HttpSetup) {} + + public async createCustomIntegration( + params: CreateCustomIntegrationRequestQuery + ): Promise { + try { + const response = await this.http.post(CUSTOM_INTEGRATIONS_URL, { + version: '2023-10-31', + body: JSON.stringify(params), + }); + + const data = decodeOrThrow( + createCustomIntegrationResponseRT, + (message: string) => + new DecodeError(`Failed to decode create custom integration response: ${message}"`) + )(response); + + return { + integrationName: params.integrationName, + installedAssets: data.items, + }; + } catch (error) { + if (error?.body?.statusCode === 409) { + throw new NamingCollisionError(error.body?.message ?? GENERIC_CREATE_ERROR_MESSAGE); + } else if (error?.body?.statusCode === 403) { + throw new AuthorizationError(error?.body?.message ?? GENERIC_CREATE_ERROR_MESSAGE); + } else if (error instanceof DecodeError) { + throw error; + } else { + throw new UnknownError(error?.body?.message ?? GENERIC_CREATE_ERROR_MESSAGE); + } + } + } + + public async deleteCustomIntegration( + params: DeleteCustomIntegrationRequestQuery + ): Promise { + const { integrationName, version } = params; + try { + await this.http.delete( + DELETE_PACKAGE_URL.replace('{pkgName}', integrationName).replace('{pkgVersion}', version), + { version: '2023-10-31' } + ); + return { + integrationName: params.integrationName, + }; + } catch (error) { + if (error?.body?.message && error.body.message.includes('is not installed')) { + throw new IntegrationNotInstalledError(error.body.message); + } else { + throw new UnknownError(error?.body?.message ?? GENERIC_DELETE_ERROR_MESSAGE); + } + } + } +} + +const assetListRT = rt.array( + rt.type({ + id: rt.string, + type: rt.string, + }) +); + +type AssetList = rt.TypeOf; + +export const createCustomIntegrationRequestQueryRT = customIntegrationOptionsRT; +export type CreateCustomIntegrationRequestQuery = rt.TypeOf< + typeof createCustomIntegrationRequestQueryRT +>; + +export const createCustomIntegrationResponseRT = rt.exact( + rt.type({ + items: assetListRT, + }) +); + +export interface CreateCustomIntegrationValue { + integrationName: string; + installedAssets: AssetList; +} + +export const deleteCustomIntegrationRequestQueryRT = rt.type({ + integrationName: rt.string, + version: rt.string, +}); + +export type DeleteCustomIntegrationRequestQuery = rt.TypeOf< + typeof deleteCustomIntegrationRequestQueryRT +>; + +export const deleteCustomIntegrationResponseRT = rt.exact( + rt.type({ + integrationName: integrationNameRT, + }) +); + +export type DeleteCustomIntegrationResponse = rt.TypeOf; diff --git a/packages/kbn-custom-integrations/src/state_machines/services/validation.ts b/packages/kbn-custom-integrations/src/state_machines/services/validation.ts new file mode 100644 index 0000000000000..3ac325ce3d3a1 --- /dev/null +++ b/packages/kbn-custom-integrations/src/state_machines/services/validation.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isEmpty } from 'lodash'; +import { InvokeCreator } from 'xstate'; +import { IntegrationError } from '../../types'; + +export class FormattingError extends IntegrationError { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +type Validator = (field: string) => IntegrationError | null; + +interface ValidatorsConfig { + [key: string]: Validator[] | ((arrayField: unknown[]) => IndexedValidationErrors | null); +} + +export interface ValidationErrors { + [key: string]: ValidationResult; +} + +export interface IndexedValidationErrors { + [key: number]: { + [key: string]: IntegrationError[]; + }; +} + +type ValidationResult = IntegrationError[] | IndexedValidationErrors; + +export const initializeValidateFields = + (validatorsConfig: ValidatorsConfig): InvokeCreator => + (context) => { + const errors = validateConfigsAgainstContext(validatorsConfig, context.fields); + if (Object.keys(errors).length > 0) { + return Promise.reject({ errors }); + } else { + return Promise.resolve(); + } + }; + +export const createIsEmptyValidation = (message: string) => (field: unknown) => + isEmpty(field) ? new FormattingError(message) : null; + +export const createIsLowerCaseValidation = (message: string) => (field: string) => + field.toLowerCase() !== field ? new FormattingError(message) : null; + +export const createCharacterLimitValidation = (message: string, limit: number) => (field: string) => + field.length > limit ? new FormattingError(message) : null; + +export const createArrayValidator = (validatorsConfig: ValidatorsConfig) => { + return (arrayField: any[]) => { + const arrayErrors = arrayField.reduce( + (indexedErrors, item, currentIndex) => { + const errorsForField = validateConfigsAgainstContext(validatorsConfig, item); + return { + ...indexedErrors, + ...(Object.keys(errorsForField).length > 0 ? { [currentIndex]: errorsForField } : {}), + } as IndexedValidationErrors; + }, + {} + ); + + return Object.keys(arrayErrors).length > 0 ? arrayErrors : null; + }; +}; + +const validateConfigsAgainstContext = (validatorsConfig: ValidatorsConfig, context: any) => { + const errors = Object.entries(validatorsConfig).reduce( + (validationErrors, validationConfig) => { + const [field, validatorsOrIndexedValidator] = validationConfig; + let errorsForField; + if (Array.isArray(validatorsOrIndexedValidator)) { + errorsForField = validatorsOrIndexedValidator + .map((validator) => validator(context[field])) + .filter((result): result is IntegrationError => result !== null); + } else { + errorsForField = validatorsOrIndexedValidator(context[field]); + } + + return { + ...validationErrors, + ...((Array.isArray(errorsForField) && errorsForField.length > 0) || + (!Array.isArray(errorsForField) && errorsForField !== null) + ? { [field]: errorsForField } + : {}), + }; + }, + {} + ); + return errors; +}; diff --git a/packages/kbn-custom-integrations/src/types.ts b/packages/kbn-custom-integrations/src/types.ts new file mode 100644 index 0000000000000..062601239137a --- /dev/null +++ b/packages/kbn-custom-integrations/src/types.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +/* eslint-disable max-classes-per-file */ +import * as rt from 'io-ts'; + +export const integrationNameRT = rt.string; + +const datasetTypes = rt.keyof({ + logs: null, + metrics: null, +}); + +const dataset = rt.exact( + rt.type({ + name: rt.string, + type: datasetTypes, + }) +); + +export type Dataset = rt.TypeOf; + +export const customIntegrationOptionsRT = rt.exact( + rt.type({ + integrationName: integrationNameRT, + datasets: rt.array(dataset), + }) +); + +export type CustomIntegrationOptions = rt.TypeOf; + +export class IntegrationError extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class NamingCollisionError extends IntegrationError { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class AuthorizationError extends IntegrationError { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class UnknownError extends IntegrationError { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class DecodeError extends IntegrationError { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class IntegrationNotInstalledError extends IntegrationError { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/packages/kbn-custom-integrations/tsconfig.json b/packages/kbn-custom-integrations/tsconfig.json new file mode 100644 index 0000000000000..cb57aee9dbeaa --- /dev/null +++ b/packages/kbn-custom-integrations/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "target/**/*", + ], + "kbn_references": [ + "@kbn/core-http-browser", + "@kbn/i18n", + "@kbn/core", + "@kbn/fleet-plugin", + "@kbn/io-ts-utils", + "@kbn/xstate-utils" + ] +} diff --git a/packages/kbn-xstate-utils/README.md b/packages/kbn-xstate-utils/README.md new file mode 100644 index 0000000000000..ba185e1b466a3 --- /dev/null +++ b/packages/kbn-xstate-utils/README.md @@ -0,0 +1,3 @@ +# @kbn/xstate-utils + +Utilities to assist with development using the xstate library. diff --git a/packages/kbn-xstate-utils/index.ts b/packages/kbn-xstate-utils/index.ts new file mode 100644 index 0000000000000..de0577ee3ed83 --- /dev/null +++ b/packages/kbn-xstate-utils/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './src'; diff --git a/packages/kbn-xstate-utils/jest.config.js b/packages/kbn-xstate-utils/jest.config.js new file mode 100644 index 0000000000000..9c747a6a128c3 --- /dev/null +++ b/packages/kbn-xstate-utils/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-xstate-utils'], +}; diff --git a/packages/kbn-xstate-utils/kibana.jsonc b/packages/kbn-xstate-utils/kibana.jsonc new file mode 100644 index 0000000000000..086bce23401aa --- /dev/null +++ b/packages/kbn-xstate-utils/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/xstate-utils", + "owner": "@elastic/infra-monitoring-ui" +} diff --git a/packages/kbn-xstate-utils/package.json b/packages/kbn-xstate-utils/package.json new file mode 100644 index 0000000000000..373931bd41c22 --- /dev/null +++ b/packages/kbn-xstate-utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/xstate-utils", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-xstate-utils/src/actions.ts b/packages/kbn-xstate-utils/src/actions.ts new file mode 100644 index 0000000000000..178f6499102ad --- /dev/null +++ b/packages/kbn-xstate-utils/src/actions.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + actions, + ActorRef, + AnyEventObject, + EventObject, + Expr, + PureAction, + SendActionOptions, +} from 'xstate'; + +export const sendIfDefined = + (target: string | ActorRef) => + ( + eventExpr: Expr, + options?: SendActionOptions + ): PureAction => { + return actions.pure((context, event) => { + const targetEvent = eventExpr(context, event); + return targetEvent != null && targetEvent !== undefined + ? [actions.sendTo(target, targetEvent, options)] + : undefined; + }); + }; diff --git a/packages/kbn-xstate-utils/src/dev_tools.ts b/packages/kbn-xstate-utils/src/dev_tools.ts new file mode 100644 index 0000000000000..fa16b808b3aec --- /dev/null +++ b/packages/kbn-xstate-utils/src/dev_tools.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const isDevMode = () => process.env.NODE_ENV !== 'production'; diff --git a/packages/kbn-xstate-utils/src/index.ts b/packages/kbn-xstate-utils/src/index.ts new file mode 100644 index 0000000000000..2cf5853db6e08 --- /dev/null +++ b/packages/kbn-xstate-utils/src/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './actions'; +export * from './notification_channel'; +export * from './types'; +export * from './dev_tools'; diff --git a/packages/kbn-xstate-utils/src/notification_channel.ts b/packages/kbn-xstate-utils/src/notification_channel.ts new file mode 100644 index 0000000000000..86f9c7f64f518 --- /dev/null +++ b/packages/kbn-xstate-utils/src/notification_channel.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ReplaySubject, Subject } from 'rxjs'; +import { ActionFunction, EventObject, Expr, Subscribable } from 'xstate'; + +export interface NotificationChannel { + createService: () => Subscribable; + notify: ( + eventExpr: Expr + ) => ActionFunction; +} + +export const createNotificationChannel = ( + shouldReplayLastEvent = true +): NotificationChannel => { + const eventsSubject = shouldReplayLastEvent + ? new ReplaySubject(1) + : new Subject(); + + const createService = () => eventsSubject.asObservable(); + + const notify = + (eventExpr: Expr) => + (context: TContext, event: TEvent) => { + const eventToSend = eventExpr(context, event); + + if (eventToSend != null) { + eventsSubject.next(eventToSend); + } + }; + + return { + createService, + notify, + }; +}; diff --git a/packages/kbn-xstate-utils/src/types.ts b/packages/kbn-xstate-utils/src/types.ts new file mode 100644 index 0000000000000..10d21697cdd28 --- /dev/null +++ b/packages/kbn-xstate-utils/src/types.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ActorRef, ActorRefWithDeprecatedState, EmittedFrom, State, StateValue } from 'xstate'; + +export type OmitDeprecatedState> = Omit< + T, + 'state' +>; + +export type MatchedState< + TState extends State, + TStateValue extends StateValue +> = TState extends State< + any, + infer TEvent, + infer TStateSchema, + infer TTypestate, + infer TResolvedTypesMeta +> + ? State< + (TTypestate extends any + ? { value: TStateValue; context: any } extends TTypestate + ? TTypestate + : never + : never)['context'], + TEvent, + TStateSchema, + TTypestate, + TResolvedTypesMeta + > & { + value: TStateValue; + } + : never; + +export type MatchedStateFromActor< + TActorRef extends ActorRef, + TStateValue extends StateValue +> = MatchedState, TStateValue>; diff --git a/packages/kbn-xstate-utils/tsconfig.json b/packages/kbn-xstate-utils/tsconfig.json new file mode 100644 index 0000000000000..2f9ddddbeea23 --- /dev/null +++ b/packages/kbn-xstate-utils/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index c345a5232b726..2c1168feaf0c5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -588,6 +588,8 @@ "@kbn/crypto-browser/*": ["packages/kbn-crypto-browser/*"], "@kbn/custom-branding-plugin": ["x-pack/plugins/custom_branding"], "@kbn/custom-branding-plugin/*": ["x-pack/plugins/custom_branding/*"], + "@kbn/custom-integrations": ["packages/kbn-custom-integrations"], + "@kbn/custom-integrations/*": ["packages/kbn-custom-integrations/*"], "@kbn/custom-integrations-plugin": ["src/plugins/custom_integrations"], "@kbn/custom-integrations-plugin/*": ["src/plugins/custom_integrations/*"], "@kbn/cypress-config": ["packages/kbn-cypress-config"], @@ -1606,6 +1608,8 @@ "@kbn/web-worker-stub/*": ["packages/kbn-web-worker-stub/*"], "@kbn/whereis-pkg-cli": ["packages/kbn-whereis-pkg-cli"], "@kbn/whereis-pkg-cli/*": ["packages/kbn-whereis-pkg-cli/*"], + "@kbn/xstate-utils": ["packages/kbn-xstate-utils"], + "@kbn/xstate-utils/*": ["packages/kbn-xstate-utils/*"], "@kbn/yarn-lock-validator": ["packages/kbn-yarn-lock-validator"], "@kbn/yarn-lock-validator/*": ["packages/kbn-yarn-lock-validator/*"], // END AUTOMATED PACKAGE LISTING diff --git a/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/configure.cy.ts b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/configure.cy.ts index 0178aca04c344..d4f95d0256870 100644 --- a/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/configure.cy.ts +++ b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/configure.cy.ts @@ -59,11 +59,11 @@ describe('[Logs onboarding] Custom logs - configure step', () => { .type('myLogs.log'); cy.getByTestSubj('obltOnboardingCustomLogsIntegrationsName').should( 'have.value', - 'myLogs' + 'mylogs' ); cy.getByTestSubj('obltOnboardingCustomLogsDatasetName').should( 'have.value', - 'myLogs' + 'mylogs' ); }); @@ -280,7 +280,7 @@ describe('[Logs onboarding] Custom logs - configure step', () => { }); it('installation fails', () => { - cy.getByTestSubj('obltOnboardingCustomIntegrationUnauthorized').should( + cy.getByTestSubj('obltOnboardingCustomIntegrationErrorCallout').should( 'exist' ); }); @@ -304,8 +304,6 @@ describe('[Logs onboarding] Custom logs - configure step', () => { }); it('installation succeed and user is redirected install elastic agent step', () => { - cy.getByTestSubj('obltOnboardingCustomLogsContinue').click(); - cy.url().should( 'include', '/app/observabilityOnboarding/customLogs/installElasticAgent' @@ -349,7 +347,7 @@ describe('[Logs onboarding] Custom logs - configure step', () => { }); it('user should see the error displayed', () => { - cy.getByTestSubj('obltOnboardingCustomIntegrationUnknownError').should( + cy.getByTestSubj('obltOnboardingCustomIntegrationErrorCallout').should( 'exist' ); }); diff --git a/x-pack/plugins/observability_onboarding/kibana.jsonc b/x-pack/plugins/observability_onboarding/kibana.jsonc index 0379aec27b496..5c1615c3a95ba 100644 --- a/x-pack/plugins/observability_onboarding/kibana.jsonc +++ b/x-pack/plugins/observability_onboarding/kibana.jsonc @@ -7,7 +7,7 @@ "server": true, "browser": true, "configPath": ["xpack", "observability_onboarding"], - "requiredPlugins": ["data", "observability", "observabilityShared", "discover", "share"], + "requiredPlugins": ["data", "observability", "observabilityShared", "discover", "share", "fleet"], "optionalPlugins": ["cloud", "usageCollection"], "requiredBundles": ["kibanaReact"], "extraPublicDirs": ["common"] diff --git a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx index 798614365d6b0..9a6d83a3c84b3 100644 --- a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx @@ -7,10 +7,8 @@ import { EuiAccordion, - EuiButton, EuiButtonEmpty, EuiButtonIcon, - EuiCallOut, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -27,13 +25,15 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { isEmpty } from 'lodash'; import React, { useCallback, useState } from 'react'; import { - IntegrationError, - IntegrationOptions, - useCreateIntegration, -} from '../../../../hooks/use_create_integration'; + ConnectedCustomIntegrationsButton, + ConnectedCustomIntegrationsForm, + useConsumerCustomIntegrations, + CustomIntegrationsProvider, + Callbacks, +} from '@kbn/custom-integrations'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useWizard } from '.'; import { OptionalFormRow } from '../../../shared/optional_form_row'; import { @@ -42,23 +42,78 @@ import { StepPanelFooter, } from '../../../shared/step_panel'; import { BackButton } from './back_button'; -import { getFilename, replaceSpecialChars } from './get_filename'; +import { getFilename } from './get_filename'; + +const customIntegrationsTestSubjects = { + create: { + integrationName: 'obltOnboardingCustomLogsIntegrationsName', + datasetName: 'obltOnboardingCustomLogsDatasetName', + errorCallout: { + callout: 'obltOnboardingCustomIntegrationErrorCallout', + }, + }, + button: 'obltOnboardingCustomLogsContinue', +}; export function ConfigureLogs() { - const [datasetNameTouched, setDatasetNameTouched] = useState(false); + const { + services: { http }, + } = useKibana(); + + const { goToStep, setState, getState } = useWizard(); + const { integrationName, datasetName, lastCreatedIntegrationOptions } = + getState(); + + const onIntegrationCreation: Callbacks['onIntegrationCreation'] = ( + integrationOptions + ) => { + const { + integrationName: createdIntegrationName, + datasets: createdDatasets, + } = integrationOptions; + setState((state) => ({ + ...state, + integrationName: createdIntegrationName, + datasetName: createdDatasets[0].name, + lastCreatedIntegrationOptions: integrationOptions, + })); + goToStep('installElasticAgent'); + }; + + return ( + + + + ); +} + +export function ConfigureLogsContent() { + const { + dispatchableEvents: { updateCreateFields }, + } = useConsumerCustomIntegrations(); const { euiTheme } = useEuiTheme(); const xsFontSize = useEuiFontSize('xs').fontSize; - const { goToStep, goBack, getState, setState } = useWizard(); + const { goBack, getState, setState } = useWizard(); const wizardState = getState(); - const [integrationName, setIntegrationName] = useState( - wizardState.integrationName - ); - const [integrationNameTouched, setIntegrationNameTouched] = useState(false); - const [integrationError, setIntegrationError] = useState< - IntegrationError | undefined - >(); - const [datasetName, setDatasetName] = useState(wizardState.datasetName); const [serviceName, setServiceName] = useState(wizardState.serviceName); const [logFilePaths, setLogFilePaths] = useState(wizardState.logFilePaths); const [namespace, setNamespace] = useState(wizardState.namespace); @@ -67,63 +122,15 @@ export function ConfigureLogs() { ); const logFilePathNotConfigured = logFilePaths.every((filepath) => !filepath); - const onIntegrationCreationSuccess = useCallback( - (integration: IntegrationOptions) => { - setState((state) => ({ - ...state, - lastCreatedIntegration: integration, - })); - goToStep('installElasticAgent'); - }, - [goToStep, setState] - ); - - const onIntegrationCreationFailure = useCallback( - (error: IntegrationError) => { - setIntegrationError(error); - }, - [setIntegrationError] - ); - - const { createIntegration, createIntegrationRequest } = useCreateIntegration({ - onIntegrationCreationSuccess, - onIntegrationCreationFailure, - initialLastCreatedIntegration: wizardState.lastCreatedIntegration, - }); - - const isCreatingIntegration = createIntegrationRequest.state === 'pending'; - const hasFailedCreatingIntegration = - createIntegrationRequest.state === 'rejected'; - const onContinue = useCallback(() => { setState((state) => ({ ...state, - datasetName, - integrationName, serviceName, logFilePaths: logFilePaths.filter((filepath) => !!filepath), namespace, customConfigurations, })); - createIntegration({ - integrationName, - datasets: [ - { - name: datasetName, - type: 'logs' as const, - }, - ], - }); - }, [ - createIntegration, - customConfigurations, - datasetName, - integrationName, - logFilePaths, - namespace, - serviceName, - setState, - ]); + }, [customConfigurations, logFilePaths, namespace, serviceName, setState]); function addLogFilePath() { setLogFilePaths((prev) => [...prev, '']); @@ -143,60 +150,31 @@ export function ConfigureLogs() { ); if (index === 0) { - setIntegrationName(getFilename(filepath)); - setDatasetName(getFilename(filepath)); + if (updateCreateFields) { + updateCreateFields({ + integrationName: getFilename(filepath).toLowerCase(), + datasets: [ + { + name: getFilename(filepath).toLowerCase(), + type: 'logs' as const, + }, + ], + }); + } } } - const hasNamingCollision = - integrationError && integrationError.type === 'NamingCollision'; - - const isIntegrationNameInvalid = - (integrationNameTouched && - (isEmpty(integrationName) || !isLowerCase(integrationName))) || - hasNamingCollision; - - const integrationNameError = getIntegrationNameError( - integrationName, - integrationNameTouched, - integrationError - ); - - const isDatasetNameInvalid = - datasetNameTouched && (isEmpty(datasetName) || !isLowerCase(datasetName)); - - const datasetNameError = getDatasetNameError(datasetName, datasetNameTouched); - return ( , - - {isCreatingIntegration - ? i18n.translate( - 'xpack.observability_onboarding.steps.loading', - { - defaultMessage: 'Creating integration...', - } - ) - : i18n.translate( - 'xpack.observability_onboarding.steps.continue', - { - defaultMessage: 'Continue', - } - )} - , + testSubj={customIntegrationsTestSubjects.button} + />, ]} /> } @@ -478,214 +456,10 @@ export function ConfigureLogs() { - -

- {i18n.translate( - 'xpack.observability_onboarding.configureLogs.configureIntegrationDescription', - { - defaultMessage: 'Configure integration', - } - )} -

-
- - - - - {i18n.translate( - 'xpack.observability_onboarding.configureLogs.integration.name', - { - defaultMessage: 'Integration name', - } - )} - - - - - - } - helpText={i18n.translate( - 'xpack.observability_onboarding.configureLogs.integration.helper', - { - defaultMessage: - "All lowercase, max 100 chars, special characters will be replaced with '_'.", - } - )} - isInvalid={isIntegrationNameInvalid} - error={integrationNameError} - > - - setIntegrationName(replaceSpecialChars(event.target.value)) - } - isInvalid={isIntegrationNameInvalid} - onInput={() => setIntegrationNameTouched(true)} - data-test-subj="obltOnboardingCustomLogsIntegrationsName" - /> - - - - {i18n.translate( - 'xpack.observability_onboarding.configureLogs.dataset.name', - { - defaultMessage: 'Dataset name', - } - )} - - - - - - } - helpText={i18n.translate( - 'xpack.observability_onboarding.configureLogs.dataset.helper', - { - defaultMessage: - "All lowercase, max 100 chars, special characters will be replaced with '_'.", - } - )} - isInvalid={isDatasetNameInvalid} - error={datasetNameError} - > - - setDatasetName(replaceSpecialChars(event.target.value)) - } - isInvalid={isDatasetNameInvalid} - onInput={() => setDatasetNameTouched(true)} - data-test-subj="obltOnboardingCustomLogsDatasetName" - /> - - - {hasFailedCreatingIntegration && integrationError && ( - <> - - {getIntegrationErrorCallout(integrationError)} - - )} +
); } - -const getIntegrationErrorCallout = (integrationError: IntegrationError) => { - const title = i18n.translate( - 'xpack.observability_onboarding.configureLogs.integrationCreation.error.title', - { defaultMessage: 'Sorry, there was an error' } - ); - - switch (integrationError.type) { - case 'AuthorizationError': - const authorizationDescription = i18n.translate( - 'xpack.observability_onboarding.configureLogs.integrationCreation.error.authorization.description', - { - defaultMessage: - 'This user does not have permissions to create an integration.', - } - ); - return ( - -

{authorizationDescription}

-
- ); - case 'UnknownError': - return ( - -

{integrationError.message}

-
- ); - } -}; - -const isLowerCase = (str: string) => str.toLowerCase() === str; - -const getIntegrationNameError = ( - integrationName: string, - touched: boolean, - integrationError?: IntegrationError -) => { - if (touched && isEmpty(integrationName)) { - return i18n.translate( - 'xpack.observability_onboarding.configureLogs.integration.emptyError', - { defaultMessage: 'An integration name is required.' } - ); - } - if (touched && !isLowerCase(integrationName)) { - return i18n.translate( - 'xpack.observability_onboarding.configureLogs.integration.lowercaseError', - { defaultMessage: 'An integration name should be lowercase.' } - ); - } - if (integrationError && integrationError.type === 'NamingCollision') { - return integrationError.message; - } -}; - -const getDatasetNameError = (datasetName: string, touched: boolean) => { - if (touched && isEmpty(datasetName)) { - return i18n.translate( - 'xpack.observability_onboarding.configureLogs.dataset.emptyError', - { defaultMessage: 'A dataset name is required.' } - ); - } - if (touched && !isLowerCase(datasetName)) { - return i18n.translate( - 'xpack.observability_onboarding.configureLogs.dataset.lowercaseError', - { defaultMessage: 'A dataset name should be lowercase.' } - ); - } -}; diff --git a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/index.tsx b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/index.tsx index fecd9c4de8384..9fb2f2ecf9536 100644 --- a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/index.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/index.tsx @@ -5,8 +5,8 @@ * 2.0. */ +import { CustomIntegrationOptions } from '@kbn/custom-integrations'; import { i18n } from '@kbn/i18n'; -import { IntegrationOptions } from '../../../../hooks/use_create_integration'; import { createWizardContext, Step, @@ -18,7 +18,7 @@ import { SelectLogs } from './select_logs'; interface WizardState { integrationName: string; - lastCreatedIntegration?: IntegrationOptions; + lastCreatedIntegrationOptions?: CustomIntegrationOptions; datasetName: string; serviceName: string; logFilePaths: string[]; diff --git a/x-pack/plugins/observability_onboarding/public/hooks/use_create_integration.ts b/x-pack/plugins/observability_onboarding/public/hooks/use_create_integration.ts deleted file mode 100644 index 15d383cb4aa42..0000000000000 --- a/x-pack/plugins/observability_onboarding/public/hooks/use_create_integration.ts +++ /dev/null @@ -1,123 +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 { useCallback, useState } from 'react'; -import deepEqual from 'react-fast-compare'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { useTrackedPromise } from '@kbn/use-tracked-promise'; -import { i18n } from '@kbn/i18n'; - -export interface IntegrationOptions { - integrationName: string; - datasets: Array<{ - name: string; - type: 'logs'; - }>; -} - -// Errors -const GENERIC_ERROR_MESSAGE = i18n.translate( - 'xpack.observability_onboarding.useCreateIntegration.integrationError.genericError', - { - defaultMessage: 'Unable to create an integration', - } -); - -type ErrorType = 'NamingCollision' | 'AuthorizationError' | 'UnknownError'; -export interface IntegrationError { - type: ErrorType; - message: string; -} - -export const useCreateIntegration = ({ - onIntegrationCreationSuccess, - onIntegrationCreationFailure, - initialLastCreatedIntegration, - deletePreviousIntegration = true, -}: { - integrationOptions?: IntegrationOptions; - onIntegrationCreationSuccess: (integration: IntegrationOptions) => void; - onIntegrationCreationFailure: (error: IntegrationError) => void; - initialLastCreatedIntegration?: IntegrationOptions; - deletePreviousIntegration?: boolean; -}) => { - const { - services: { http }, - } = useKibana(); - const [lastCreatedIntegration, setLastCreatedIntegration] = useState< - IntegrationOptions | undefined - >(initialLastCreatedIntegration); - - const [createIntegrationRequest, callCreateIntegration] = useTrackedPromise( - { - cancelPreviousOn: 'creation', - createPromise: async (integrationOptions) => { - if (lastCreatedIntegration && deletePreviousIntegration) { - await http?.delete( - `/api/fleet/epm/packages/${lastCreatedIntegration.integrationName}/1.0.0`, - {} - ); - } - await http?.post('/api/fleet/epm/custom_integrations', { - body: JSON.stringify(integrationOptions), - }); - - return integrationOptions; - }, - onResolve: (integrationOptions: IntegrationOptions) => { - setLastCreatedIntegration(integrationOptions); - onIntegrationCreationSuccess(integrationOptions!); - }, - onReject: (requestError: any) => { - if (requestError?.body?.statusCode === 409) { - onIntegrationCreationFailure({ - type: 'NamingCollision' as const, - message: requestError.body.message, - }); - } else if (requestError?.body?.statusCode === 403) { - onIntegrationCreationFailure({ - type: 'AuthorizationError' as const, - message: requestError?.body?.message, - }); - } else { - onIntegrationCreationFailure({ - type: 'UnknownError' as const, - message: requestError?.body?.message ?? GENERIC_ERROR_MESSAGE, - }); - } - }, - }, - [ - lastCreatedIntegration, - deletePreviousIntegration, - onIntegrationCreationSuccess, - onIntegrationCreationFailure, - setLastCreatedIntegration, - ] - ); - - const createIntegration = useCallback( - (integrationOptions: IntegrationOptions) => { - // Bypass creating the integration again - if (deepEqual(integrationOptions, lastCreatedIntegration)) { - onIntegrationCreationSuccess(integrationOptions); - } else { - callCreateIntegration(integrationOptions); - } - }, - [ - callCreateIntegration, - lastCreatedIntegration, - onIntegrationCreationSuccess, - ] - ); - - return { - createIntegration, - createIntegrationRequest, - }; -}; diff --git a/x-pack/plugins/observability_onboarding/tsconfig.json b/x-pack/plugins/observability_onboarding/tsconfig.json index d2b4d9a223a2b..3188e63982483 100644 --- a/x-pack/plugins/observability_onboarding/tsconfig.json +++ b/x-pack/plugins/observability_onboarding/tsconfig.json @@ -33,6 +33,7 @@ "@kbn/data-views-plugin", "@kbn/es-query", "@kbn/use-tracked-promise", + "@kbn/custom-integrations", "@kbn/share-plugin", "@kbn/utility-types", ], diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 0ab39bd6ce4d2..ad1f9d581caa0 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -27043,9 +27043,6 @@ "xpack.observability_onboarding.card.systemLogs.title": "Collecter des logs système", "xpack.observability_onboarding.configureLogs.advancedSettings": "Paramètres avancés", "xpack.observability_onboarding.configureLogs.customConfig": "Configurations personnalisées", - "xpack.observability_onboarding.configureLogs.dataset.helper": "Choisissez un nom pour vos logs. Tout en minuscules, 100 caractères maximum, les caractères spéciaux seront remplacés par \"_\".", - "xpack.observability_onboarding.configureLogs.dataset.name": "Nom de l’ensemble de données", - "xpack.observability_onboarding.configureLogs.dataset.placeholder": "Nom de l’ensemble de données", "xpack.observability_onboarding.configureLogs.description": "Remplissez les chemins d’accès aux fichiers log sur vos hôtes.", "xpack.observability_onboarding.configureLogs.learnMore": "En savoir plus", "xpack.observability_onboarding.configureLogs.logFile.addRow": "Ajouter une ligne", @@ -27105,7 +27102,6 @@ "xpack.observability_onboarding.selectLogs.useOwnShipper": "Obtenir une clé d’API", "xpack.observability_onboarding.selectLogs.useOwnShipper.description": "Utilisez votre propre agent de transfert pour collecter des données de logs en générant une clé d’API.", "xpack.observability_onboarding.steps.back": "Retour", - "xpack.observability_onboarding.steps.continue": "Continuer", "xpack.observability_onboarding.steps.exploreLogs": "Explorer les logs", "xpack.observability_onboarding.steps.inspect": "Inspecter", "xpack.observability_onboarding.title.collectCustomLogs": "Collectez des logs personnalisés", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2c138f9531944..feb4f26c63bea 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -27043,9 +27043,6 @@ "xpack.observability_onboarding.card.systemLogs.title": "システムログを収集", "xpack.observability_onboarding.configureLogs.advancedSettings": "高度な設定", "xpack.observability_onboarding.configureLogs.customConfig": "カスタム構成", - "xpack.observability_onboarding.configureLogs.dataset.helper": "ログの名前を設定します。すべて小文字、最大100文字、特殊文字は「_」に置き換えられます。", - "xpack.observability_onboarding.configureLogs.dataset.name": "データセット名", - "xpack.observability_onboarding.configureLogs.dataset.placeholder": "データセット名", "xpack.observability_onboarding.configureLogs.description": "ホスト上のログファイルへのパスを入力します。", "xpack.observability_onboarding.configureLogs.learnMore": "詳細", "xpack.observability_onboarding.configureLogs.logFile.addRow": "行の追加", @@ -27105,7 +27102,6 @@ "xpack.observability_onboarding.selectLogs.useOwnShipper": "APIキーを取得", "xpack.observability_onboarding.selectLogs.useOwnShipper.description": "APIキーを生成し、ログデータを収集するために独自のシッパーを使用します。", "xpack.observability_onboarding.steps.back": "戻る", - "xpack.observability_onboarding.steps.continue": "続行", "xpack.observability_onboarding.steps.exploreLogs": "ログを探索", "xpack.observability_onboarding.steps.inspect": "検査", "xpack.observability_onboarding.title.collectCustomLogs": "カスタムログを収集", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 4b32fb1565140..bce73e0732ed3 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -27041,9 +27041,6 @@ "xpack.observability_onboarding.card.systemLogs.title": "收集系统日志", "xpack.observability_onboarding.configureLogs.advancedSettings": "高级设置", "xpack.observability_onboarding.configureLogs.customConfig": "定制配置", - "xpack.observability_onboarding.configureLogs.dataset.helper": "选取日志的名称。全部小写,最多 100 个字符,将用“_”替代特殊字符。", - "xpack.observability_onboarding.configureLogs.dataset.name": "数据集名称", - "xpack.observability_onboarding.configureLogs.dataset.placeholder": "数据集名称", "xpack.observability_onboarding.configureLogs.description": "填写日志文件在主机上的路径。", "xpack.observability_onboarding.configureLogs.learnMore": "了解详情", "xpack.observability_onboarding.configureLogs.logFile.addRow": "添加行", @@ -27103,7 +27100,6 @@ "xpack.observability_onboarding.selectLogs.useOwnShipper": "获取 API 密钥", "xpack.observability_onboarding.selectLogs.useOwnShipper.description": "通过生成 API 密钥使用您自己的采集器来收集日志数据。", "xpack.observability_onboarding.steps.back": "返回", - "xpack.observability_onboarding.steps.continue": "继续", "xpack.observability_onboarding.steps.exploreLogs": "浏览日志", "xpack.observability_onboarding.steps.inspect": "检查", "xpack.observability_onboarding.title.collectCustomLogs": "收集定制日志", diff --git a/yarn.lock b/yarn.lock index b67834221e56a..d0494ef6a3858 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4122,6 +4122,10 @@ version "0.0.0" uid "" +"@kbn/custom-integrations@link:packages/kbn-custom-integrations": + version "0.0.0" + uid "" + "@kbn/cypress-config@link:packages/kbn-cypress-config": version "0.0.0" uid "" @@ -6154,6 +6158,10 @@ version "0.0.0" uid "" +"@kbn/xstate-utils@link:packages/kbn-xstate-utils": + version "0.0.0" + uid "" + "@kbn/yarn-lock-validator@link:packages/kbn-yarn-lock-validator": version "0.0.0" uid ""