mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Logs+] Extract custom integration resources to package (#165510)
## Summary This closes #163788. ## High level overview - Adds a new `kbn-custom-integrations` package. - This package adds a new top level custom integrations state machine, which manages a child create custom integration state machine. In the future we will have additional modes (such as adding a dataset to an existing integration, and various "uplift" flows). - Adds connected (to the machine) components that consumers can use to facilitate custom integration workflows. - Adds a `kbn-xstate-utils` package (as these utils were in 2 plugins and now 1 package). - Replaces the integration creation inside of the onboarding wizard flow with this package. - At the moment this is locked down to `logs`, and one dataset, but it can be easily extended in the future to support all types and multiple datasets. The state machine is ready, it just needs exposed in the UI. - Some thought has gone in to how this will work with multiple "modes", and the foundations are there (imagining that certain types will be unions etc), however it's worth not getting too bogged down in those specific implementation details as I'd rather base that evolution on the real world usage when we have it. The Configure integration section should more or less work the same as before.  ## Testing - When utilising the onboarding flow for custom logs at `/app/observabilityOnboarding/customLogs` can you: - Create a custom integration? (It's worth verifying the network requests, and the assets are installed). - If you navigate forward, then back, make a change to the integration fields, and navigate forward again is the previously created integration deleted? - Is the success callout with the integration name shown on the next wizard panel? - Do field validations work? - Are errors displayed when you try to create an integration with a name that already exists? - Can you retry when there is a server error? (you can block network requests to the custom integrations API to test this) ## Screenshots     ## State machine diagram (The top level management machine is super basic, so this is just the create machine)  ## Followups - Tests (the current onboarding UI implementation doesn't have tests so whilst it's not ideal technically this coverage stays the same) - Storybook - Replace other plugins' usage with xstate-utils (not urgent) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Yngrid Coello <yngrid.coello@elastic.co>
This commit is contained in:
parent
0bbe7b15a8
commit
afcdc59348
57 changed files with 2337 additions and 462 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
90
packages/kbn-custom-integrations/README.md
Normal file
90
packages/kbn-custom-integrations/README.md
Normal file
|
@ -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
|
||||
<CustomIntegrationsProvider
|
||||
services={{ http }}
|
||||
onIntegrationCreation={onIntegrationCreation}
|
||||
initialState={{
|
||||
mode: 'create',
|
||||
fields: {
|
||||
integrationName,
|
||||
datasets: [{ name: datasetName, type: 'logs' as const }],
|
||||
},
|
||||
previouslyCreatedIntegration: lastCreatedIntegrationOptions,
|
||||
}}
|
||||
>
|
||||
<ConfigureLogsContent />
|
||||
</CustomIntegrationsProvider>
|
||||
```
|
||||
|
||||
2. Include Connected form and button components
|
||||
|
||||
```ts
|
||||
<ConnectedCustomIntegrationsForm />
|
||||
```
|
||||
|
||||
The form will internally interact with the backing state machines.
|
||||
|
||||
```ts
|
||||
<ConnectedCustomIntegrationsButton
|
||||
isDisabled={logFilePathNotConfigured || !namespace}
|
||||
onClick={onContinue}
|
||||
/>
|
||||
```
|
||||
|
||||
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.
|
19
packages/kbn-custom-integrations/index.ts
Normal file
19
packages/kbn-custom-integrations/index.ts
Normal file
|
@ -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';
|
13
packages/kbn-custom-integrations/jest.config.js
Normal file
13
packages/kbn-custom-integrations/jest.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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: ['<rootDir>/packages/kbn-custom-integrations'],
|
||||
};
|
5
packages/kbn-custom-integrations/kibana.jsonc
Normal file
5
packages/kbn-custom-integrations/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/custom-integrations",
|
||||
"owner": "@elastic/infra-monitoring-ui"
|
||||
}
|
6
packages/kbn-custom-integrations/package.json
Normal file
6
packages/kbn-custom-integrations/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/custom-integrations",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
|
@ -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 (
|
||||
<CreateCustomIntegrationButton
|
||||
onClick={onClick}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
isDisabled={isDisabled}
|
||||
submittingText={submittingText}
|
||||
continueText={continueText}
|
||||
testSubj={testSubj}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type CreateCustomIntegrationButtonProps = {
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
} & Omit<ConnectedCreateCustomIntegrationButtonProps, 'machine'>;
|
||||
|
||||
const CreateCustomIntegrationButton = ({
|
||||
onClick,
|
||||
isValid,
|
||||
isSubmitting,
|
||||
isDisabled,
|
||||
submittingText,
|
||||
continueText,
|
||||
testSubj,
|
||||
}: CreateCustomIntegrationButtonProps) => {
|
||||
return (
|
||||
<EuiButton
|
||||
data-test-subj={testSubj}
|
||||
color="primary"
|
||||
fill
|
||||
onClick={onClick}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isDisabled || !isValid}
|
||||
>
|
||||
{isSubmitting ? submittingText : continueText}
|
||||
</EuiButton>
|
||||
);
|
||||
};
|
|
@ -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 (
|
||||
<BaseErrorCallout
|
||||
message={authorizationDescription}
|
||||
onRetry={onRetry}
|
||||
testSubjects={testSubjects}
|
||||
/>
|
||||
);
|
||||
} else if (error instanceof UnknownError || error instanceof IntegrationNotInstalledError) {
|
||||
return (
|
||||
<BaseErrorCallout message={error.message} onRetry={onRetry} testSubjects={testSubjects} />
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const BaseErrorCallout = ({
|
||||
message,
|
||||
onRetry,
|
||||
testSubjects,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
testSubjects?: CreateTestSubjects['errorCallout'];
|
||||
}) => {
|
||||
return (
|
||||
<EuiCallOut
|
||||
title={TITLE}
|
||||
color="danger"
|
||||
iconType="error"
|
||||
data-test-subj={testSubjects?.callout ?? 'customIntegrationsPackageCreateFormErrorCallout'}
|
||||
>
|
||||
<>
|
||||
<p>{message}</p>
|
||||
{onRetry ? (
|
||||
<EuiButton
|
||||
data-test-subj={
|
||||
testSubjects?.retryButton ??
|
||||
'customIntegrationsPackageCreateFormErrorCalloutRetryButton'
|
||||
}
|
||||
color="danger"
|
||||
size="s"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{RETRY_TEXT}
|
||||
</EuiButton>
|
||||
) : null}
|
||||
</>
|
||||
</EuiCallOut>
|
||||
);
|
||||
};
|
236
packages/kbn-custom-integrations/src/components/create/form.tsx
Normal file
236
packages/kbn-custom-integrations/src/components/create/form.tsx
Normal file
|
@ -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 (
|
||||
<CreateCustomIntegrationForm
|
||||
integrationName={state?.context.fields.integrationName}
|
||||
datasetName={state?.context.fields.datasets[0].name} // NOTE: Hardcoded for now until we support multiple datasets
|
||||
errors={state.context.errors}
|
||||
updateIntegrationName={updateIntegrationName}
|
||||
updateDatasetName={updateDatasetName}
|
||||
touchedFields={state?.context.touchedFields}
|
||||
hasFailed={hasFailed}
|
||||
onRetry={hasFailed ? retry : undefined}
|
||||
testSubjects={testSubjects}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<EuiText color="subdued">
|
||||
<p>
|
||||
{i18n.translate('customIntegrationsPackage.create.configureIntegrationDescription', {
|
||||
defaultMessage: 'Configure integration',
|
||||
})}
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiForm fullWidth>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate('customIntegrationsPackage.create.integration.name', {
|
||||
defaultMessage: 'Integration name',
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip
|
||||
content={i18n.translate(
|
||||
'customIntegrationsPackage.create.integration.name.tooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'Provide a name for the integration that will be created to organise these custom logs.',
|
||||
}
|
||||
)}
|
||||
position="right"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
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)}
|
||||
>
|
||||
<EuiFieldText
|
||||
placeholder={i18n.translate(
|
||||
'customIntegrationsPackage.create.integration.placeholder',
|
||||
{
|
||||
defaultMessage: 'Give your integration a name',
|
||||
}
|
||||
)}
|
||||
value={integrationName}
|
||||
onChange={(event) => updateIntegrationName(event.target.value)}
|
||||
isInvalid={hasErrors(errors?.fields?.integrationName) && touchedFields.integrationName}
|
||||
max={100}
|
||||
data-test-subj={
|
||||
testSubjects?.integrationName ??
|
||||
'customIntegrationsPackageCreateFormIntegrationNameInput'
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate('customIntegrationsPackage.create.dataset.name', {
|
||||
defaultMessage: 'Dataset name',
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip
|
||||
content={i18n.translate('customIntegrationsPackage.create.dataset.name.tooltip', {
|
||||
defaultMessage:
|
||||
'Provide a dataset name to help organise these custom logs. This dataset will be associated with the integration.',
|
||||
})}
|
||||
position="right"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
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)}
|
||||
>
|
||||
<EuiFieldText
|
||||
placeholder={i18n.translate('customIntegrationsPackage.create.dataset.placeholder', {
|
||||
defaultMessage: "Give your integration's dataset a name",
|
||||
})}
|
||||
value={datasetName}
|
||||
onChange={(event) => updateDatasetName(event.target.value)}
|
||||
isInvalid={hasErrors(errors?.fields?.datasets?.[0].name) && touchedFields.datasets}
|
||||
max={100}
|
||||
data-test-subj={
|
||||
testSubjects?.datasetName ?? 'customIntegrationsPackageCreateFormDatasetNameInput'
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
{errors?.general && (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<ErrorCallout
|
||||
error={errors?.general}
|
||||
onRetry={onRetry}
|
||||
testSubjects={testSubjects?.errorCallout}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const hasErrors = (errors?: IntegrationError[]) => errors && errors.length > 0;
|
||||
|
||||
const errorsList = (errors?: IntegrationError[]) => {
|
||||
return hasErrors(errors) ? (
|
||||
<ul>
|
||||
{errors!.map((error, index) => (
|
||||
<li key={index}>{error.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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 (
|
||||
<ConnectedCreateCustomIntegrationButton
|
||||
machine={customIntegrationsState.children.createCustomIntegration}
|
||||
isDisabled={isDisabled}
|
||||
onClick={onClick}
|
||||
testSubj={testSubj}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
|
@ -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 (
|
||||
<ConnectedCreateCustomIntegrationForm
|
||||
machineRef={customIntegrationsState.children.createCustomIntegration}
|
||||
testSubjects={testSubjects?.create}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
12
packages/kbn-custom-integrations/src/components/index.ts
Normal file
12
packages/kbn-custom-integrations/src/components/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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';
|
|
@ -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<CreateCustomIntegrationActorRef>(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<CreateCustomIntegrationOptions>) =>
|
||||
send({ type: 'UPDATE_FIELDS', fields })
|
||||
: undefined,
|
||||
};
|
||||
}, [isUninitialized, isValid, send]);
|
||||
|
||||
return dispatchableEvents;
|
||||
};
|
||||
|
||||
export type CreateDispatchableEvents = ReturnType<typeof useCreateDispatchableEvents>;
|
11
packages/kbn-custom-integrations/src/hooks/index.ts
Normal file
11
packages/kbn-custom-integrations/src/hooks/index.ts
Normal file
|
@ -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';
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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),
|
||||
};
|
|
@ -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');
|
|
@ -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<WithTouchedFields['touchedFields']>(
|
||||
(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<CreateCustomIntegrationStateMachine>
|
||||
>;
|
||||
export type CreateCustomIntegrationState = EmittedFrom<CreateCustomIntegrationActorRef>;
|
|
@ -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<keyof CreateCustomIntegrationOptions, boolean>;
|
||||
}
|
||||
|
||||
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<CreateCustomIntegrationOptions>;
|
||||
}
|
||||
| {
|
||||
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;
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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
|
||||
);
|
|
@ -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' });
|
|
@ -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<CustomIntegrationsContext, CustomIntegrationsEvent, CustomIntegrationsTypestate>(
|
||||
{
|
||||
/** @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<CustomIntegrationsStateMachine>
|
||||
>;
|
||||
export type CustomIntegrationsState = EmittedFrom<CustomIntegrationsActorRef>;
|
|
@ -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<CreateInitialState>;
|
||||
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;
|
11
packages/kbn-custom-integrations/src/state_machines/index.ts
Normal file
11
packages/kbn-custom-integrations/src/state_machines/index.ts
Normal file
|
@ -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';
|
|
@ -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<CreateCustomIntegrationValue>;
|
||||
deleteCustomIntegration(
|
||||
params?: DeleteCustomIntegrationRequestQuery
|
||||
): Promise<DeleteCustomIntegrationResponse>;
|
||||
}
|
||||
|
||||
export class IntegrationsClient implements IIntegrationsClient {
|
||||
constructor(private readonly http: HttpSetup) {}
|
||||
|
||||
public async createCustomIntegration(
|
||||
params: CreateCustomIntegrationRequestQuery
|
||||
): Promise<CreateCustomIntegrationValue> {
|
||||
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<DeleteCustomIntegrationResponse> {
|
||||
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<typeof assetListRT>;
|
||||
|
||||
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<typeof deleteCustomIntegrationResponseRT>;
|
|
@ -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<any, any> =>
|
||||
(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<IndexedValidationErrors>(
|
||||
(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>(
|
||||
(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;
|
||||
};
|
76
packages/kbn-custom-integrations/src/types.ts
Normal file
76
packages/kbn-custom-integrations/src/types.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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<typeof dataset>;
|
||||
|
||||
export const customIntegrationOptionsRT = rt.exact(
|
||||
rt.type({
|
||||
integrationName: integrationNameRT,
|
||||
datasets: rt.array(dataset),
|
||||
})
|
||||
);
|
||||
|
||||
export type CustomIntegrationOptions = rt.TypeOf<typeof customIntegrationOptionsRT>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
25
packages/kbn-custom-integrations/tsconfig.json
Normal file
25
packages/kbn-custom-integrations/tsconfig.json
Normal file
|
@ -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"
|
||||
]
|
||||
}
|
3
packages/kbn-xstate-utils/README.md
Normal file
3
packages/kbn-xstate-utils/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/xstate-utils
|
||||
|
||||
Utilities to assist with development using the xstate library.
|
9
packages/kbn-xstate-utils/index.ts
Normal file
9
packages/kbn-xstate-utils/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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';
|
13
packages/kbn-xstate-utils/jest.config.js
Normal file
13
packages/kbn-xstate-utils/jest.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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: ['<rootDir>/packages/kbn-xstate-utils'],
|
||||
};
|
5
packages/kbn-xstate-utils/kibana.jsonc
Normal file
5
packages/kbn-xstate-utils/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/xstate-utils",
|
||||
"owner": "@elastic/infra-monitoring-ui"
|
||||
}
|
6
packages/kbn-xstate-utils/package.json
Normal file
6
packages/kbn-xstate-utils/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/xstate-utils",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
31
packages/kbn-xstate-utils/src/actions.ts
Normal file
31
packages/kbn-xstate-utils/src/actions.ts
Normal file
|
@ -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 =
|
||||
<TSentEvent extends EventObject = AnyEventObject>(target: string | ActorRef<TSentEvent>) =>
|
||||
<TContext, TEvent extends EventObject>(
|
||||
eventExpr: Expr<TContext, TEvent, TSentEvent | undefined | null>,
|
||||
options?: SendActionOptions<TContext, TEvent>
|
||||
): PureAction<TContext, TEvent> => {
|
||||
return actions.pure((context, event) => {
|
||||
const targetEvent = eventExpr(context, event);
|
||||
return targetEvent != null && targetEvent !== undefined
|
||||
? [actions.sendTo(target, targetEvent, options)]
|
||||
: undefined;
|
||||
});
|
||||
};
|
9
packages/kbn-xstate-utils/src/dev_tools.ts
Normal file
9
packages/kbn-xstate-utils/src/dev_tools.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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';
|
12
packages/kbn-xstate-utils/src/index.ts
Normal file
12
packages/kbn-xstate-utils/src/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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';
|
42
packages/kbn-xstate-utils/src/notification_channel.ts
Normal file
42
packages/kbn-xstate-utils/src/notification_channel.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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<TContext, TEvent extends EventObject, TSentEvent> {
|
||||
createService: () => Subscribable<TSentEvent>;
|
||||
notify: (
|
||||
eventExpr: Expr<TContext, TEvent, TSentEvent | undefined>
|
||||
) => ActionFunction<TContext, TEvent>;
|
||||
}
|
||||
|
||||
export const createNotificationChannel = <TContext, TEvent extends EventObject, TSentEvent>(
|
||||
shouldReplayLastEvent = true
|
||||
): NotificationChannel<TContext, TEvent, TSentEvent> => {
|
||||
const eventsSubject = shouldReplayLastEvent
|
||||
? new ReplaySubject<TSentEvent>(1)
|
||||
: new Subject<TSentEvent>();
|
||||
|
||||
const createService = () => eventsSubject.asObservable();
|
||||
|
||||
const notify =
|
||||
(eventExpr: Expr<TContext, TEvent, TSentEvent | undefined>) =>
|
||||
(context: TContext, event: TEvent) => {
|
||||
const eventToSend = eventExpr(context, event);
|
||||
|
||||
if (eventToSend != null) {
|
||||
eventsSubject.next(eventToSend);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createService,
|
||||
notify,
|
||||
};
|
||||
};
|
44
packages/kbn-xstate-utils/src/types.ts
Normal file
44
packages/kbn-xstate-utils/src/types.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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<T extends ActorRefWithDeprecatedState<any, any, any, any>> = Omit<
|
||||
T,
|
||||
'state'
|
||||
>;
|
||||
|
||||
export type MatchedState<
|
||||
TState extends State<any, any, any, any, any>,
|
||||
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<any, any>,
|
||||
TStateValue extends StateValue
|
||||
> = MatchedState<EmittedFrom<TActorRef>, TStateValue>;
|
17
packages/kbn-xstate-utils/tsconfig.json
Normal file
17
packages/kbn-xstate-utils/tsconfig.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": []
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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 (
|
||||
<CustomIntegrationsProvider
|
||||
services={{ http }}
|
||||
onIntegrationCreation={onIntegrationCreation}
|
||||
initialState={{
|
||||
mode: 'create',
|
||||
context: {
|
||||
options: {
|
||||
deletePrevious: true,
|
||||
resetOnCreation: false,
|
||||
errorOnFailedCleanup: false,
|
||||
},
|
||||
fields: {
|
||||
integrationName,
|
||||
datasets: [{ name: datasetName, type: 'logs' as const }],
|
||||
},
|
||||
previouslyCreatedIntegration: lastCreatedIntegrationOptions,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ConfigureLogsContent />
|
||||
</CustomIntegrationsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<StepPanel
|
||||
panelFooter={
|
||||
<StepPanelFooter
|
||||
items={[
|
||||
<BackButton onBack={goBack} />,
|
||||
<EuiButton
|
||||
color="primary"
|
||||
fill
|
||||
<ConnectedCustomIntegrationsButton
|
||||
isDisabled={logFilePathNotConfigured || !namespace}
|
||||
onClick={onContinue}
|
||||
isLoading={isCreatingIntegration}
|
||||
isDisabled={
|
||||
logFilePathNotConfigured || !datasetName || !namespace
|
||||
}
|
||||
data-test-subj="obltOnboardingCustomLogsContinue"
|
||||
>
|
||||
{isCreatingIntegration
|
||||
? i18n.translate(
|
||||
'xpack.observability_onboarding.steps.loading',
|
||||
{
|
||||
defaultMessage: 'Creating integration...',
|
||||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.observability_onboarding.steps.continue',
|
||||
{
|
||||
defaultMessage: 'Continue',
|
||||
}
|
||||
)}
|
||||
</EuiButton>,
|
||||
testSubj={customIntegrationsTestSubjects.button}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
}
|
||||
|
@ -478,214 +456,10 @@ export function ConfigureLogs() {
|
|||
</EuiFlexGroup>
|
||||
</EuiForm>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiText color="subdued">
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.observability_onboarding.configureLogs.configureIntegrationDescription',
|
||||
{
|
||||
defaultMessage: 'Configure integration',
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiForm fullWidth>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="xs"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate(
|
||||
'xpack.observability_onboarding.configureLogs.integration.name',
|
||||
{
|
||||
defaultMessage: 'Integration name',
|
||||
}
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip
|
||||
content={i18n.translate(
|
||||
'xpack.observability_onboarding.configureLogs.integration.name.tooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'Provide an integration name for the integration that will be created to organise these custom logs. Defaults to the name of the log file.',
|
||||
}
|
||||
)}
|
||||
position="right"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
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}
|
||||
>
|
||||
<EuiFieldText
|
||||
placeholder={i18n.translate(
|
||||
'xpack.observability_onboarding.configureLogs.integration.placeholder',
|
||||
{
|
||||
defaultMessage: 'Give your integration a name',
|
||||
}
|
||||
)}
|
||||
value={integrationName}
|
||||
onChange={(event) =>
|
||||
setIntegrationName(replaceSpecialChars(event.target.value))
|
||||
}
|
||||
isInvalid={isIntegrationNameInvalid}
|
||||
onInput={() => setIntegrationNameTouched(true)}
|
||||
data-test-subj="obltOnboardingCustomLogsIntegrationsName"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="xs"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate(
|
||||
'xpack.observability_onboarding.configureLogs.dataset.name',
|
||||
{
|
||||
defaultMessage: 'Dataset name',
|
||||
}
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip
|
||||
content={i18n.translate(
|
||||
'xpack.observability_onboarding.configureLogs.dataset.name.tooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'Provide a dataset name to help organise these custom logs. This dataset will be associated with the integration. Defaults to the name of the log file.',
|
||||
}
|
||||
)}
|
||||
position="right"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
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}
|
||||
>
|
||||
<EuiFieldText
|
||||
placeholder={i18n.translate(
|
||||
'xpack.observability_onboarding.configureLogs.dataset.placeholder',
|
||||
{
|
||||
defaultMessage: "Give your integration's dataset a name",
|
||||
}
|
||||
)}
|
||||
value={datasetName}
|
||||
onChange={(event) =>
|
||||
setDatasetName(replaceSpecialChars(event.target.value))
|
||||
}
|
||||
isInvalid={isDatasetNameInvalid}
|
||||
onInput={() => setDatasetNameTouched(true)}
|
||||
data-test-subj="obltOnboardingCustomLogsDatasetName"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
{hasFailedCreatingIntegration && integrationError && (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
{getIntegrationErrorCallout(integrationError)}
|
||||
</>
|
||||
)}
|
||||
<ConnectedCustomIntegrationsForm
|
||||
testSubjects={customIntegrationsTestSubjects}
|
||||
/>
|
||||
</StepPanelContent>
|
||||
</StepPanel>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<EuiCallOut
|
||||
title={title}
|
||||
color="danger"
|
||||
iconType="error"
|
||||
data-test-subj="obltOnboardingCustomIntegrationUnauthorized"
|
||||
>
|
||||
<p>{authorizationDescription}</p>
|
||||
</EuiCallOut>
|
||||
);
|
||||
case 'UnknownError':
|
||||
return (
|
||||
<EuiCallOut
|
||||
title={title}
|
||||
color="danger"
|
||||
iconType="error"
|
||||
data-test-subj="obltOnboardingCustomIntegrationUnknownError"
|
||||
>
|
||||
<p>{integrationError.message}</p>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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.' }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -33,6 +33,7 @@
|
|||
"@kbn/data-views-plugin",
|
||||
"@kbn/es-query",
|
||||
"@kbn/use-tracked-promise",
|
||||
"@kbn/custom-integrations",
|
||||
"@kbn/share-plugin",
|
||||
"@kbn/utility-types",
|
||||
],
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "カスタムログを収集",
|
||||
|
|
|
@ -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": "收集定制日志",
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue