if the editor is not visible
-
- )}
-
+ );
+ }}
+
>
);
};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx
index 6dcedca6085af..dd2439433fc41 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx
@@ -4,13 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, FunctionComponent } from 'react';
import { Pipeline } from '../../../../../common/types';
import { useFormContext } from '../../../../shared_imports';
+
+import { ReadProcessorsFunction } from '../types';
+
import { PipelineRequestFlyout } from './pipeline_request_flyout';
-export const PipelineRequestFlyoutProvider = ({ closeFlyout }: { closeFlyout: () => void }) => {
+interface Props {
+ closeFlyout: () => void;
+ readProcessors: ReadProcessorsFunction;
+}
+
+export const PipelineRequestFlyoutProvider: FunctionComponent
= ({
+ closeFlyout,
+ readProcessors,
+}) => {
const form = useFormContext();
const [formData, setFormData] = useState({} as Pipeline);
@@ -25,5 +36,10 @@ export const PipelineRequestFlyoutProvider = ({ closeFlyout }: { closeFlyout: ()
return subscription.unsubscribe;
}, [form]);
- return ;
+ return (
+
+ );
};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx
index 351478394595a..7f91672d64df4 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx
@@ -8,11 +8,19 @@ import React, { useState, useEffect } from 'react';
import { Pipeline } from '../../../../../common/types';
import { useFormContext } from '../../../../shared_imports';
+
+import { ReadProcessorsFunction } from '../types';
+
import { PipelineTestFlyout, PipelineTestFlyoutProps } from './pipeline_test_flyout';
-type Props = Omit;
+interface Props extends Omit {
+ readProcessors: ReadProcessorsFunction;
+}
-export const PipelineTestFlyoutProvider: React.FunctionComponent = ({ closeFlyout }) => {
+export const PipelineTestFlyoutProvider: React.FunctionComponent = ({
+ closeFlyout,
+ readProcessors,
+}) => {
const form = useFormContext();
const [formData, setFormData] = useState({} as Pipeline);
const [isFormDataValid, setIsFormDataValid] = useState(false);
@@ -31,7 +39,7 @@ export const PipelineTestFlyoutProvider: React.FunctionComponent = ({ clo
return (
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/types.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/types.ts
new file mode 100644
index 0000000000000..bd74f09546ff4
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/types.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Pipeline } from '../../../../common/types';
+
+export type ReadProcessorsFunction = () => Pick;
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.ts
new file mode 100644
index 0000000000000..acd61a9bbd01e
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { registerTestBed, TestBed } from '../../../../../../../test_utils';
+import { PipelineProcessorsEditor, Props } from '../pipeline_processors_editor.container';
+
+const testBedSetup = registerTestBed(PipelineProcessorsEditor, {
+ doMountAsync: false,
+});
+
+export interface SetupResult extends TestBed {
+ actions: {
+ toggleOnFailure: () => void;
+ };
+}
+
+export const setup = async (props: Props): Promise => {
+ const testBed = await testBedSetup(props);
+ const toggleOnFailure = () => {
+ const { find } = testBed;
+ find('pipelineEditorOnFailureToggle').simulate('click');
+ };
+
+ return {
+ ...testBed,
+ actions: { toggleOnFailure },
+ };
+};
+
+type TestSubject =
+ | 'pipelineEditorDoneButton'
+ | 'pipelineEditorOnFailureToggle'
+ | 'pipelineEditorOnFailureTree';
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx
new file mode 100644
index 0000000000000..758d6f5e620ce
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { setup } from './pipeline_processors_editor.helpers';
+import { Pipeline } from '../../../../../common/types';
+
+const testProcessors: Pick = {
+ processors: [
+ {
+ script: {
+ source: 'ctx._type = null',
+ },
+ },
+ {
+ gsub: {
+ field: '_index',
+ pattern: '(.monitoring-\\w+-)6(-.+)',
+ replacement: '$17$2',
+ },
+ },
+ ],
+};
+
+describe('Pipeline Editor', () => {
+ it('provides the same data out it got in if nothing changes', async () => {
+ const onUpdate = jest.fn();
+
+ await setup({
+ value: {
+ ...testProcessors,
+ },
+ onFlyoutOpen: jest.fn(),
+ onUpdate,
+ isTestButtonDisabled: false,
+ onTestPipelineClick: jest.fn(),
+ learnMoreAboutProcessorsUrl: 'test',
+ learnMoreAboutOnFailureProcessorsUrl: 'test',
+ });
+
+ const {
+ calls: [[arg]],
+ } = onUpdate.mock;
+
+ expect(arg.getData()).toEqual(testProcessors);
+ });
+
+ it('toggles the on-failure processors', async () => {
+ const { actions, exists } = await setup({
+ value: {
+ ...testProcessors,
+ },
+ onFlyoutOpen: jest.fn(),
+ onUpdate: jest.fn(),
+ isTestButtonDisabled: false,
+ onTestPipelineClick: jest.fn(),
+ learnMoreAboutProcessorsUrl: 'test',
+ learnMoreAboutOnFailureProcessorsUrl: 'test',
+ });
+
+ expect(exists('pipelineEditorOnFailureTree')).toBe(false);
+ actions.toggleOnFailure();
+ expect(exists('pipelineEditorOnFailureTree')).toBe(true);
+ });
+});
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx
new file mode 100644
index 0000000000000..5f9bf87ceca1e
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent } from 'react';
+import { i18n } from '@kbn/i18n';
+import { EuiButtonEmpty } from '@elastic/eui';
+import { usePipelineProcessorsContext } from '../context';
+
+export interface Props {
+ onClick: () => void;
+}
+
+export const AddProcessorButton: FunctionComponent = ({ onClick }) => {
+ const {
+ state: { editor },
+ } = usePipelineProcessorsContext();
+ return (
+
+ {i18n.translate('xpack.ingestPipelines.pipelineEditor.addProcessorButtonLabel', {
+ defaultMessage: 'Add a processor',
+ })}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts
new file mode 100644
index 0000000000000..cb5d5a10e9f42
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { SettingsFormFlyout, OnSubmitHandler } from './settings_form_flyout';
+
+export { ProcessorSettingsForm, ProcessorSettingsFromOnSubmitArg } from './processor_settings_form';
+
+export { ProcessorsTree, ProcessorInfo, OnActionHandler } from './processors_tree';
+
+export { PipelineProcessorsEditorItem } from './pipeline_processors_editor_item/pipeline_processors_editor_item';
+
+export { ProcessorRemoveModal } from './processor_remove_modal';
+
+export { ProcessorsTitleAndTestButton } from './processors_title_and_test_button';
+
+export { OnFailureProcessorsTitle } from './on_failure_processors_title';
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx
new file mode 100644
index 0000000000000..1c8edac7cfd64
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx
@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiTitle } from '@elastic/eui';
+import React, { FunctionComponent } from 'react';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { usePipelineProcessorsContext } from '../context';
+
+export const OnFailureProcessorsTitle: FunctionComponent = () => {
+ const { links } = usePipelineProcessorsContext();
+ return (
+
+
+
+
+
+
+
+
+
+ {i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.onFailureProcessorsDocumentationLink',
+ {
+ defaultMessage: 'Learn more.',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx
new file mode 100644
index 0000000000000..bc7d6fdcff357
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent, useState } from 'react';
+
+import { EuiContextMenuItem, EuiContextMenuPanel, EuiPopover, EuiButtonIcon } from '@elastic/eui';
+
+import { editorItemMessages } from './messages';
+
+interface Props {
+ disabled: boolean;
+ showAddOnFailure: boolean;
+ onDuplicate: () => void;
+ onDelete: () => void;
+ onAddOnFailure: () => void;
+}
+
+export const ContextMenu: FunctionComponent = ({
+ showAddOnFailure,
+ onDuplicate,
+ onAddOnFailure,
+ onDelete,
+ disabled,
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const contextMenuItems = [
+ {
+ setIsOpen(false);
+ onDuplicate();
+ }}
+ >
+ {editorItemMessages.duplicateButtonLabel}
+ ,
+ showAddOnFailure ? (
+ {
+ setIsOpen(false);
+ onAddOnFailure();
+ }}
+ >
+ {editorItemMessages.addOnFailureButtonLabel}
+
+ ) : undefined,
+ {
+ setIsOpen(false);
+ onDelete();
+ }}
+ >
+ {editorItemMessages.deleteButtonLabel}
+ ,
+ ].filter(Boolean) as JSX.Element[];
+
+ return (
+ setIsOpen(false)}
+ button={
+ setIsOpen((v) => !v)}
+ iconType="boxesHorizontal"
+ aria-label={editorItemMessages.moreButtonAriaLabel}
+ />
+ }
+ >
+
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/index.ts
new file mode 100644
index 0000000000000..02bafdb326024
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { PipelineProcessorsEditorItem, Handlers } from './pipeline_processors_editor_item';
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx
new file mode 100644
index 0000000000000..e0b67bc907ca9
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent, useState, useEffect, useCallback } from 'react';
+import { EuiFieldText, EuiText, keyCodes } from '@elastic/eui';
+
+export interface Props {
+ placeholder: string;
+ ariaLabel: string;
+ onChange: (value: string) => void;
+ text?: string;
+}
+
+export const InlineTextInput: FunctionComponent = ({
+ placeholder,
+ text,
+ ariaLabel,
+ onChange,
+}) => {
+ const [isShowingTextInput, setIsShowingTextInput] = useState(false);
+ const [textValue, setTextValue] = useState(text ?? '');
+
+ const content = isShowingTextInput ? (
+ el?.focus()}
+ onChange={(event) => setTextValue(event.target.value)}
+ />
+ ) : (
+
+ {text || {placeholder}}
+
+ );
+
+ const submitChange = useCallback(() => {
+ setIsShowingTextInput(false);
+ onChange(textValue);
+ }, [setIsShowingTextInput, onChange, textValue]);
+
+ useEffect(() => {
+ const keyboardListener = (event: KeyboardEvent) => {
+ if (event.keyCode === keyCodes.ESCAPE || event.code === 'Escape') {
+ setIsShowingTextInput(false);
+ }
+ if (event.keyCode === keyCodes.ENTER || event.code === 'Enter') {
+ submitChange();
+ }
+ };
+ if (isShowingTextInput) {
+ window.addEventListener('keyup', keyboardListener);
+ }
+ return () => {
+ window.removeEventListener('keyup', keyboardListener);
+ };
+ }, [isShowingTextInput, submitChange, setIsShowingTextInput]);
+
+ return (
+ setIsShowingTextInput(true)}
+ onBlur={submitChange}
+ >
+ {content}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts
new file mode 100644
index 0000000000000..67dbf2708d665
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const editorItemMessages = {
+ moveButtonLabel: i18n.translate('xpack.ingestPipelines.pipelineEditor.item.moveButtonLabel', {
+ defaultMessage: 'Move this processor',
+ }),
+ editorButtonLabel: i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.item.editButtonAriaLabel',
+ {
+ defaultMessage: 'Edit this processor',
+ }
+ ),
+ duplicateButtonLabel: i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.item.moreMenu.duplicateButtonLabel',
+ {
+ defaultMessage: 'Duplicate this processor',
+ }
+ ),
+ addOnFailureButtonLabel: i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.item.moreMenu.addOnFailureHandlerButtonLabel',
+ {
+ defaultMessage: 'Add on failure handler',
+ }
+ ),
+ cancelMoveButtonLabel: i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.item.cancelMoveButtonAriaLabel',
+ {
+ defaultMessage: 'Cancel moving this processor',
+ }
+ ),
+ deleteButtonLabel: i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.item.moreMenu.deleteButtonLabel',
+ {
+ defaultMessage: 'Delete',
+ }
+ ),
+ moreButtonAriaLabel: i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.item.moreButtonAriaLabel',
+ {
+ defaultMessage: 'Show more actions for this processor',
+ }
+ ),
+ processorTypeLabel: ({ type }: { type: string }) =>
+ i18n.translate('xpack.ingestPipelines.pipelineEditor.item.textInputAriaLabel', {
+ defaultMessage: 'Provide a description for this {type} processor',
+ values: { type },
+ }),
+ descriptionPlaceholder: i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.item.descriptionPlaceholder',
+ { defaultMessage: 'No description' }
+ ),
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss
new file mode 100644
index 0000000000000..a17e644853847
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss
@@ -0,0 +1,17 @@
+.pipelineProcessorsEditor__item {
+ &__textContainer {
+ padding: 4px;
+ border-radius: 2px;
+
+ transition: border-color .3s;
+ border: 2px solid #FFF;
+
+ &:hover {
+ border: 2px solid $euiColorLightShade;
+ }
+ }
+ &__textInput {
+ height: 21px;
+ min-width: 100px;
+ }
+}
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx
new file mode 100644
index 0000000000000..0e47b3ef7cf88
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx
@@ -0,0 +1,145 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent, memo } from 'react';
+import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui';
+
+import { ProcessorInternal, ProcessorSelector } from '../../types';
+
+import { usePipelineProcessorsContext } from '../../context';
+
+import './pipeline_processors_editor_item.scss';
+
+import { InlineTextInput } from './inline_text_input';
+import { ContextMenu } from './context_menu';
+import { editorItemMessages } from './messages';
+
+export interface Handlers {
+ onMove: () => void;
+ onCancelMove: () => void;
+}
+
+export interface Props {
+ processor: ProcessorInternal;
+ selected: boolean;
+ handlers: Handlers;
+ selector: ProcessorSelector;
+ description?: string;
+}
+
+export const PipelineProcessorsEditorItem: FunctionComponent = memo(
+ ({ processor, description, handlers: { onCancelMove, onMove }, selector, selected }) => {
+ const {
+ state: { editor, processorsDispatch },
+ } = usePipelineProcessorsContext();
+
+ const disabled = editor.mode.id !== 'idle';
+ const isDarkBold =
+ editor.mode.id !== 'editingProcessor' || processor.id === editor.mode.arg.processor.id;
+
+ return (
+
+
+
+
+
+ {processor.type}
+
+
+
+ {
+ let nextOptions: Record;
+ if (!nextDescription) {
+ const { description: __, ...restOptions } = processor.options;
+ nextOptions = restOptions;
+ } else {
+ nextOptions = {
+ ...processor.options,
+ description: nextDescription,
+ };
+ }
+ processorsDispatch({
+ type: 'updateProcessor',
+ payload: {
+ processor: {
+ ...processor,
+ options: nextOptions,
+ },
+ selector,
+ },
+ });
+ }}
+ ariaLabel={editorItemMessages.processorTypeLabel({ type: processor.type })}
+ text={description}
+ placeholder={editorItemMessages.descriptionPlaceholder}
+ />
+
+
+ {
+ editor.setMode({
+ id: 'editingProcessor',
+ arg: { processor, selector },
+ });
+ }}
+ />
+
+
+ {selected ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {
+ editor.setMode({ id: 'creatingProcessor', arg: { selector } });
+ }}
+ onDelete={() => {
+ editor.setMode({ id: 'removingProcessor', arg: { selector } });
+ }}
+ onDuplicate={() => {
+ processorsDispatch({
+ type: 'duplicateProcessor',
+ payload: {
+ source: selector,
+ },
+ });
+ }}
+ />
+
+
+ );
+ }
+);
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_remove_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_remove_modal.tsx
new file mode 100644
index 0000000000000..c38e470b36699
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_remove_modal.tsx
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { FormattedMessage } from '@kbn/i18n/react';
+import React from 'react';
+import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
+import { ProcessorInternal, ProcessorSelector } from '../types';
+
+interface Props {
+ processor: ProcessorInternal;
+ selector: ProcessorSelector;
+ onResult: (arg: { confirmed: boolean; selector: ProcessorSelector }) => void;
+}
+
+export const ProcessorRemoveModal = ({ processor, onResult, selector }: Props) => {
+ return (
+
+
+ }
+ onCancel={() => onResult({ confirmed: false, selector })}
+ onConfirm={() => onResult({ confirmed: true, selector })}
+ cancelButtonText={
+
+ }
+ confirmButtonText={
+
+ }
+ >
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts
new file mode 100644
index 0000000000000..60a1aa0a96fb1
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export {
+ ProcessorSettingsForm,
+ ProcessorSettingsFromOnSubmitArg,
+} from './processor_settings_form.container';
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx
new file mode 100644
index 0000000000000..e8164a0057d39
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FunctionComponent } from 'react';
+
+// import { SetProcessor } from './processors/set';
+// import { Gsub } from './processors/gsub';
+
+const mapProcessorTypeToForm = {
+ append: undefined, // TODO: Implement
+ bytes: undefined, // TODO: Implement
+ circle: undefined, // TODO: Implement
+ convert: undefined, // TODO: Implement
+ csv: undefined, // TODO: Implement
+ date: undefined, // TODO: Implement
+ date_index_name: undefined, // TODO: Implement
+ dissect: undefined, // TODO: Implement
+ dot_expander: undefined, // TODO: Implement
+ drop: undefined, // TODO: Implement
+ enrich: undefined, // TODO: Implement
+ fail: undefined, // TODO: Implement
+ foreach: undefined, // TODO: Implement
+ geoip: undefined, // TODO: Implement
+ grok: undefined, // TODO: Implement
+ html_strip: undefined, // TODO: Implement
+ inference: undefined, // TODO: Implement
+ join: undefined, // TODO: Implement
+ json: undefined, // TODO: Implement
+ kv: undefined, // TODO: Implement
+ lowercase: undefined, // TODO: Implement
+ pipeline: undefined, // TODO: Implement
+ remove: undefined, // TODO: Implement
+ rename: undefined, // TODO: Implement
+ script: undefined, // TODO: Implement
+ set_security_user: undefined, // TODO: Implement
+ split: undefined, // TODO: Implement
+ sort: undefined, // TODO: Implement
+ trim: undefined, // TODO: Implement
+ uppercase: undefined, // TODO: Implement
+ urldecode: undefined, // TODO: Implement
+ user_agent: undefined, // TODO: Implement
+
+ gsub: undefined,
+ set: undefined,
+};
+
+export const types = Object.keys(mapProcessorTypeToForm);
+
+export type ProcessorType = keyof typeof mapProcessorTypeToForm;
+
+export const getProcessorForm = (type: ProcessorType | string): FunctionComponent | undefined => {
+ return mapProcessorTypeToForm[type as ProcessorType];
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx
new file mode 100644
index 0000000000000..29b52ef84600a
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent, useCallback, useEffect } from 'react';
+
+import { useForm, OnFormUpdateArg, FormData } from '../../../../../shared_imports';
+import { ProcessorInternal } from '../../types';
+
+import { ProcessorSettingsForm as ViewComponent } from './processor_settings_form';
+
+export type ProcessorSettingsFromOnSubmitArg = Omit;
+
+interface Props {
+ onFormUpdate: (form: OnFormUpdateArg) => void;
+ onSubmit: (processor: ProcessorSettingsFromOnSubmitArg) => void;
+ processor?: ProcessorInternal;
+}
+
+export const ProcessorSettingsForm: FunctionComponent = ({
+ processor,
+ onFormUpdate,
+ onSubmit,
+}) => {
+ const handleSubmit = useCallback(
+ async (data: FormData, isValid: boolean) => {
+ if (isValid) {
+ const { type, customOptions, ...options } = data;
+ onSubmit({
+ type,
+ options: customOptions ? customOptions : options,
+ });
+ }
+ },
+ [onSubmit]
+ );
+
+ const { form } = useForm({
+ defaultValue: processor?.options,
+ onSubmit: handleSubmit,
+ });
+
+ useEffect(() => {
+ const subscription = form.subscribe(onFormUpdate);
+ return subscription.unsubscribe;
+
+ // TODO: Address this issue
+ // For some reason adding `form` object to the dependencies array here is causing an
+ // infinite update loop.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [onFormUpdate]);
+
+ return ;
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx
new file mode 100644
index 0000000000000..49bde2129aab6
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx
@@ -0,0 +1,73 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { i18n } from '@kbn/i18n';
+import React, { FunctionComponent, memo } from 'react';
+import { EuiButton, EuiHorizontalRule } from '@elastic/eui';
+
+import { Form, useForm, FormDataProvider } from '../../../../../shared_imports';
+
+import { ProcessorInternal } from '../../types';
+
+import { getProcessorForm } from './map_processor_type_to_form';
+import { CommonProcessorFields, ProcessorTypeField } from './processors/common_fields';
+import { Custom } from './processors/custom';
+
+export interface Props {
+ processor?: ProcessorInternal;
+ form: ReturnType['form'];
+}
+
+export const ProcessorSettingsForm: FunctionComponent = memo(
+ ({ processor, form }) => {
+ return (
+
+ );
+ },
+ (previous, current) => {
+ return previous.processor === current.processor;
+ }
+);
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/common_processor_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/common_processor_fields.tsx
new file mode 100644
index 0000000000000..4802653f9e680
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/common_processor_fields.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent } from 'react';
+import { i18n } from '@kbn/i18n';
+
+import {
+ FieldConfig,
+ UseField,
+ FIELD_TYPES,
+ Field,
+ ToggleField,
+} from '../../../../../../../shared_imports';
+
+const ignoreFailureConfig: FieldConfig = {
+ defaultValue: false,
+ label: i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.commonFields.ignoreFailureFieldLabel',
+ {
+ defaultMessage: 'Ignore failure',
+ }
+ ),
+ type: FIELD_TYPES.TOGGLE,
+};
+
+const ifConfig: FieldConfig = {
+ defaultValue: undefined,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.ifFieldLabel', {
+ defaultMessage: 'Condition (optional)',
+ }),
+ type: FIELD_TYPES.TEXT,
+};
+
+const tagConfig: FieldConfig = {
+ defaultValue: undefined,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.tagFieldLabel', {
+ defaultMessage: 'Tag (optional)',
+ }),
+ type: FIELD_TYPES.TEXT,
+};
+
+export const CommonProcessorFields: FunctionComponent = () => {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/index.ts
new file mode 100644
index 0000000000000..f3fa0e028faaa
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { ProcessorTypeField } from './processor_type_field';
+
+export { CommonProcessorFields } from './common_processor_fields';
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx
new file mode 100644
index 0000000000000..6c86fc16bcdd0
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { i18n } from '@kbn/i18n';
+import React, { FunctionComponent } from 'react';
+import {
+ FIELD_TYPES,
+ FieldConfig,
+ UseField,
+ fieldValidators,
+ ComboBoxField,
+} from '../../../../../../../shared_imports';
+import { types } from '../../map_processor_type_to_form';
+
+interface Props {
+ initialType?: string;
+}
+
+const { emptyField } = fieldValidators;
+
+const typeConfig: FieldConfig = {
+ type: FIELD_TYPES.COMBO_BOX,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.typeField.typeFieldLabel', {
+ defaultMessage: 'Processor',
+ }),
+ deserializer: (value: string | undefined) => {
+ if (value) {
+ return [value];
+ }
+ return [];
+ },
+ serializer: (value: string[]) => {
+ return value[0];
+ },
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate('xpack.ingestPipelines.pipelineEditor.typeField.fieldRequiredError', {
+ defaultMessage: 'A type is required.',
+ })
+ ),
+ },
+ ],
+};
+
+export const ProcessorTypeField: FunctionComponent = ({ initialType }) => {
+ return (
+ ({ label: type, value: type })),
+ noSuggestions: false,
+ singleSelection: {
+ asPlainText: true,
+ },
+ },
+ }}
+ />
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx
new file mode 100644
index 0000000000000..61fc31a7b472a
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx
@@ -0,0 +1,90 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent } from 'react';
+import { i18n } from '@kbn/i18n';
+
+import {
+ FieldConfig,
+ FIELD_TYPES,
+ fieldValidators,
+ UseField,
+ JsonEditorField,
+} from '../../../../../../shared_imports';
+
+const { emptyField, isJsonField } = fieldValidators;
+
+const customConfig: FieldConfig = {
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldLabel', {
+ defaultMessage: 'Configuration options',
+ }),
+ serializer: (value: string) => {
+ try {
+ return JSON.parse(value);
+ } catch (error) {
+ // swallow error and return non-parsed value;
+ return value;
+ }
+ },
+ deserializer: (value: any) => {
+ if (value === '') {
+ return '{\n\n}';
+ }
+ return JSON.stringify(value, null, 2);
+ },
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.customForm.configurationRequiredError',
+ {
+ defaultMessage: 'Configuration options are required.',
+ }
+ )
+ ),
+ },
+ {
+ validator: isJsonField(
+ i18n.translate('xpack.ingestPipelines.pipelineEditor.customForm.invalidJsonError', {
+ defaultMessage: 'The input is not valid.',
+ })
+ ),
+ },
+ ],
+};
+
+interface Props {
+ defaultOptions?: any;
+}
+
+/**
+ * This is a catch-all component to support settings for custom processors
+ * or existing processors not yet supported by the UI.
+ *
+ * We store the settings in a field called "customOptions"
+ **/
+export const Custom: FunctionComponent = ({ defaultOptions }) => {
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/gsub.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/gsub.tsx
new file mode 100644
index 0000000000000..77f85e61eff6b
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/gsub.tsx
@@ -0,0 +1,98 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent } from 'react';
+import { i18n } from '@kbn/i18n';
+
+import {
+ FieldConfig,
+ FIELD_TYPES,
+ fieldValidators,
+ ToggleField,
+ UseField,
+ Field,
+} from '../../../../../../shared_imports';
+
+const { emptyField } = fieldValidators;
+
+const fieldConfig: FieldConfig = {
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.fieldFieldLabel', {
+ defaultMessage: 'Field',
+ }),
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.fieldRequiredError', {
+ defaultMessage: 'A field value is required.',
+ })
+ ),
+ },
+ ],
+};
+
+const patternConfig: FieldConfig = {
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternFieldLabel', {
+ defaultMessage: 'Pattern',
+ }),
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternRequiredError', {
+ defaultMessage: 'A pattern value is required.',
+ })
+ ),
+ },
+ ],
+};
+
+const replacementConfig: FieldConfig = {
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.replacementFieldLabel', {
+ defaultMessage: 'Replacement',
+ }),
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.replacementRequiredError', {
+ defaultMessage: 'A replacement value is required.',
+ })
+ ),
+ },
+ ],
+};
+
+const targetConfig: FieldConfig = {
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.targetFieldLabel', {
+ defaultMessage: 'Target field (optional)',
+ }),
+};
+
+const ignoreMissingConfig: FieldConfig = {
+ defaultValue: false,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.ignoreMissingFieldLabel', {
+ defaultMessage: 'Ignore missing',
+ }),
+ type: FIELD_TYPES.TOGGLE,
+};
+
+export const Gsub: FunctionComponent = () => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/set.tsx
new file mode 100644
index 0000000000000..1ba6a14d0448d
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/set.tsx
@@ -0,0 +1,74 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent } from 'react';
+import { i18n } from '@kbn/i18n';
+
+import {
+ FieldConfig,
+ FIELD_TYPES,
+ fieldValidators,
+ ToggleField,
+ UseField,
+ Field,
+} from '../../../../../../shared_imports';
+
+const { emptyField } = fieldValidators;
+
+const fieldConfig: FieldConfig = {
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.fieldFieldLabel', {
+ defaultMessage: 'Field',
+ }),
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.fieldRequiredError', {
+ defaultMessage: 'A field value is required.',
+ })
+ ),
+ },
+ ],
+};
+
+const valueConfig: FieldConfig = {
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueFieldLabel', {
+ defaultMessage: 'Value',
+ }),
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueRequiredError', {
+ defaultMessage: 'A value to set is required.',
+ })
+ ),
+ },
+ ],
+};
+
+const overrideConfig: FieldConfig = {
+ defaultValue: false,
+ label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldLabel', {
+ defaultMessage: 'Override',
+ }),
+ type: FIELD_TYPES.TOGGLE,
+};
+
+/**
+ * Disambiguate name from the Set data structure
+ */
+export const SetProcessor: FunctionComponent = () => {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx
new file mode 100644
index 0000000000000..bc646c9eefa55
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx
@@ -0,0 +1,73 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent } from 'react';
+import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiTitle } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { usePipelineProcessorsContext } from '../context';
+
+export interface Props {
+ onTestPipelineClick: () => void;
+ isTestButtonDisabled: boolean;
+}
+
+export const ProcessorsTitleAndTestButton: FunctionComponent = ({
+ onTestPipelineClick,
+ isTestButtonDisabled,
+}) => {
+ const { links } = usePipelineProcessorsContext();
+ return (
+
+
+
+
+ {i18n.translate('xpack.ingestPipelines.pipelineEditor.processorsTreeTitle', {
+ defaultMessage: 'Processors',
+ })}
+
+
+
+
+ {i18n.translate(
+ 'xpack.ingestPipelines.pipelineEditor.processorsDocumentationLink',
+ {
+ defaultMessage: 'Learn more.',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx
new file mode 100644
index 0000000000000..a47886292cf32
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import React, { FunctionComponent } from 'react';
+import classNames from 'classnames';
+import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui';
+
+export interface Props {
+ isDisabled: boolean;
+ onClick: (event: React.MouseEvent) => void;
+}
+
+const MOVE_HERE_LABEL = i18n.translate('xpack.ingestPipelines.pipelineEditor.moveTargetLabel', {
+ defaultMessage: 'Move here',
+});
+
+export const DropZoneButton: FunctionComponent = ({ onClick, isDisabled }) => {
+ const containerClasses = classNames({
+ 'pipelineProcessorsEditor__tree__dropZoneContainer--active': !isDisabled,
+ });
+ const buttonClasses = classNames({
+ 'pipelineProcessorsEditor__tree__dropZoneButton--active': !isDisabled,
+ });
+
+ return (
+
+
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/index.ts
new file mode 100644
index 0000000000000..e9548624d2cef
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/index.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { DropZoneButton } from './drop_zone_button';
+
+export { PrivateTree } from './private_tree';
+
+export { TreeNode } from './tree_node';
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx
new file mode 100644
index 0000000000000..bdc6b2eb44e2d
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx
@@ -0,0 +1,210 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent, MutableRefObject, useEffect } from 'react';
+import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { AutoSizer, List, WindowScroller } from 'react-virtualized';
+
+import { DropSpecialLocations } from '../../../constants';
+import { ProcessorInternal, ProcessorSelector } from '../../../types';
+import { isChildPath } from '../../../processors_reducer';
+
+import { DropZoneButton } from '.';
+import { TreeNode } from '.';
+import { calculateItemHeight } from '../utils';
+import { OnActionHandler, ProcessorInfo } from '../processors_tree';
+
+export interface PrivateProps {
+ processors: ProcessorInternal[];
+ selector: ProcessorSelector;
+ onAction: OnActionHandler;
+ level: number;
+ movingProcessor?: ProcessorInfo;
+ // Only passed into the top level list
+ windowScrollerRef?: MutableRefObject;
+ listRef?: MutableRefObject;
+}
+
+const isDropZoneAboveDisabled = (processor: ProcessorInfo, selectedProcessor: ProcessorInfo) => {
+ return Boolean(
+ // Is the selected node first in a list?
+ (!selectedProcessor.aboveId && selectedProcessor.id === processor.id) ||
+ isChildPath(selectedProcessor.selector, processor.selector)
+ );
+};
+
+const isDropZoneBelowDisabled = (processor: ProcessorInfo, selectedProcessor: ProcessorInfo) => {
+ return (
+ processor.id === selectedProcessor.id ||
+ processor.belowId === selectedProcessor.id ||
+ isChildPath(selectedProcessor.selector, processor.selector)
+ );
+};
+
+/**
+ * Recursively rendering tree component for ingest pipeline processors.
+ *
+ * Note: this tree should start at level 1. It is the only level at
+ * which we render the optimised virtual component. This gives a
+ * massive performance boost to this component which can get very tall.
+ *
+ * The first level list also contains the outside click listener which
+ * enables users to click outside of the tree and cancel moving a
+ * processor.
+ */
+export const PrivateTree: FunctionComponent = ({
+ processors,
+ selector,
+ movingProcessor,
+ onAction,
+ level,
+ windowScrollerRef,
+ listRef,
+}) => {
+ const renderRow = ({
+ idx,
+ info,
+ processor,
+ }: {
+ idx: number;
+ info: ProcessorInfo;
+ processor: ProcessorInternal;
+ }) => {
+ return (
+ <>
+ {idx === 0 ? (
+ {
+ event.preventDefault();
+ onAction({
+ type: 'move',
+ payload: {
+ destination: selector.concat(DropSpecialLocations.top),
+ source: movingProcessor!.selector,
+ },
+ });
+ }}
+ isDisabled={Boolean(
+ !movingProcessor || isDropZoneAboveDisabled(info, movingProcessor!)
+ )}
+ />
+ ) : undefined}
+
+
+
+ {
+ event.preventDefault();
+ onAction({
+ type: 'move',
+ payload: {
+ destination: selector.concat(String(idx + 1)),
+ source: movingProcessor!.selector,
+ },
+ });
+ }}
+ />
+ >
+ );
+ };
+
+ useEffect(() => {
+ if (windowScrollerRef && windowScrollerRef.current) {
+ windowScrollerRef.current.updatePosition();
+ }
+ if (listRef && listRef.current) {
+ listRef.current.recomputeRowHeights();
+ }
+ }, [processors, listRef, windowScrollerRef, movingProcessor]);
+
+ // A list optimized to handle very many items.
+ const renderVirtualList = () => {
+ return (
+
+ {({ height, registerChild, isScrolling, onChildScroll, scrollTop }: any) => {
+ return (
+
+
+ {({ width }) => {
+ return (
+
+
{
+ const processor = processors[index];
+ return calculateItemHeight({
+ processor,
+ isFirstInArray: index === 0,
+ });
+ }}
+ rowRenderer={({ index: idx, style }) => {
+ const processor = processors[idx];
+ const above = processors[idx - 1];
+ const below = processors[idx + 1];
+ const info: ProcessorInfo = {
+ id: processor.id,
+ selector: selector.concat(String(idx)),
+ aboveId: above?.id,
+ belowId: below?.id,
+ };
+
+ return (
+
+ {renderRow({ processor, info, idx })}
+
+ );
+ }}
+ processors={processors}
+ />
+
+ );
+ }}
+
+
+ );
+ }}
+
+ );
+ };
+
+ if (level === 1) {
+ // Only render the optimised list for the top level list because that is the list
+ // that will almost certainly be the tallest
+ return renderVirtualList();
+ }
+
+ return (
+
+ {processors.map((processor, idx) => {
+ const above = processors[idx - 1];
+ const below = processors[idx + 1];
+ const info: ProcessorInfo = {
+ id: processor.id,
+ selector: selector.concat(String(idx)),
+ aboveId: above?.id,
+ belowId: below?.id,
+ };
+
+ return {renderRow({ processor, idx, info })}
;
+ })}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx
new file mode 100644
index 0000000000000..ebe4ca4962b4c
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx
@@ -0,0 +1,115 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent, useMemo } from 'react';
+import classNames from 'classnames';
+import { i18n } from '@kbn/i18n';
+import { EuiPanel, EuiText } from '@elastic/eui';
+
+import { ProcessorInternal } from '../../../types';
+
+import { ProcessorInfo, OnActionHandler } from '../processors_tree';
+
+import { PipelineProcessorsEditorItem, Handlers } from '../../pipeline_processors_editor_item';
+import { AddProcessorButton } from '../../add_processor_button';
+
+import { PrivateTree } from './private_tree';
+
+export interface Props {
+ processor: ProcessorInternal;
+ processorInfo: ProcessorInfo;
+ onAction: OnActionHandler;
+ level: number;
+ movingProcessor?: ProcessorInfo;
+}
+
+const INDENTATION_PX = 34;
+
+export const TreeNode: FunctionComponent = ({
+ processor,
+ processorInfo,
+ onAction,
+ movingProcessor,
+ level,
+}) => {
+ const stringSelector = processorInfo.selector.join('.');
+ const handlers = useMemo((): Handlers => {
+ return {
+ onMove: () => {
+ onAction({ type: 'selectToMove', payload: { info: processorInfo } });
+ },
+ onCancelMove: () => {
+ onAction({ type: 'cancelMove' });
+ },
+ };
+ }, [onAction, stringSelector, processor]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const selected = movingProcessor?.id === processor.id;
+
+ const panelClasses = classNames({
+ 'pipelineProcessorsEditor__tree__item--selected': selected,
+ });
+
+ const renderOnFailureHandlersTree = () => {
+ if (!processor.onFailure?.length) {
+ return;
+ }
+
+ const onFailureHandlerLabelClasses = classNames({
+ 'pipelineProcessorsEditor__tree__onFailureHandlerLabel--withDropZone':
+ movingProcessor != null &&
+ movingProcessor.id !== processor.onFailure[0].id &&
+ movingProcessor.id !== processor.id,
+ });
+
+ return (
+
+
+
+ {i18n.translate('xpack.ingestPipelines.pipelineEditor.onFailureProcessorsLabel', {
+ defaultMessage: 'Failure handlers',
+ })}
+
+
+
+
+ onAction({
+ type: 'addProcessor',
+ payload: { target: processorInfo.selector.concat('onFailure') },
+ })
+ }
+ />
+
+ );
+ };
+
+ return (
+
+
+ {renderOnFailureHandlersTree()}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/index.ts
new file mode 100644
index 0000000000000..5a09794fd4bee
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { ProcessorsTree, OnActionHandler, ProcessorInfo } from './processors_tree';
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss
new file mode 100644
index 0000000000000..ad9058cea5e18
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss
@@ -0,0 +1,74 @@
+@import '@elastic/eui/src/global_styling/variables/size';
+
+.pipelineProcessorsEditor__tree {
+
+ &__container {
+ background-color: $euiColorLightestShade;
+ padding: $euiSizeS;
+ }
+
+ &__dropZoneContainer {
+ margin: 2px;
+ visibility: hidden;
+ border: 2px dashed $euiColorLightShade;
+ height: 12px;
+ border-radius: 2px;
+
+ transition: border .5s;
+
+ &--active {
+ &:hover {
+ border: 2px dashed $euiColorPrimary;
+ }
+ visibility: visible;
+ }
+ }
+
+ &__dropZoneButton {
+ height: 8px;
+ opacity: 0;
+ text-decoration: none !important;
+
+ &--active {
+ &:hover {
+ transform: none !important;
+ }
+ }
+
+ &:disabled {
+ cursor: default !important;
+ & > * {
+ cursor: default !important;
+ }
+ }
+ }
+
+ &__onFailureHandlerLabelContainer {
+ position: relative;
+ height: 14px;
+ }
+ &__onFailureHandlerLabel {
+ position: absolute;
+ bottom: -16px;
+ &--withDropZone {
+ bottom: -4px;
+ }
+ }
+
+
+ &__onFailureHandlerContainer {
+ margin-top: $euiSizeS;
+ margin-bottom: $euiSizeS;
+ & > * {
+ overflow: visible;
+ }
+ }
+
+ &__item {
+ transition: border-color 1s;
+ min-height: 50px;
+ &--selected {
+ border: 1px solid $euiColorPrimary;
+ }
+ }
+}
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx
new file mode 100644
index 0000000000000..d0661913515b2
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx
@@ -0,0 +1,110 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React, { FunctionComponent, memo, useRef, useEffect } from 'react';
+import { EuiFlexGroup, EuiFlexItem, keyCodes } from '@elastic/eui';
+import { List, WindowScroller } from 'react-virtualized';
+
+import { ProcessorInternal, ProcessorSelector } from '../../types';
+
+import './processors_tree.scss';
+import { AddProcessorButton } from '../add_processor_button';
+import { PrivateTree } from './components';
+
+export interface ProcessorInfo {
+ id: string;
+ selector: ProcessorSelector;
+ aboveId?: string;
+ belowId?: string;
+}
+
+export type Action =
+ | { type: 'move'; payload: { source: ProcessorSelector; destination: ProcessorSelector } }
+ | { type: 'selectToMove'; payload: { info: ProcessorInfo } }
+ | { type: 'cancelMove' }
+ | { type: 'addProcessor'; payload: { target: ProcessorSelector } };
+
+export type OnActionHandler = (action: Action) => void;
+
+export interface Props {
+ processors: ProcessorInternal[];
+ baseSelector: ProcessorSelector;
+ onAction: OnActionHandler;
+ movingProcessor?: ProcessorInfo;
+ 'data-test-subj'?: string;
+}
+
+/**
+ * This component is the public interface to our optimised tree rendering private components and
+ * also contains top-level state concerns for an instance of the component
+ */
+export const ProcessorsTree: FunctionComponent = memo((props) => {
+ const { processors, baseSelector, onAction, movingProcessor } = props;
+ // These refs are created here so they can be shared with all
+ // recursively rendered trees. Their values should come from react-virtualized
+ // List component and WindowScroller component.
+ const windowScrollerRef = useRef(null);
+ const listRef = useRef(null);
+
+ useEffect(() => {
+ const cancelMoveKbListener = (event: KeyboardEvent) => {
+ // x-browser support per https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode
+ if (event.keyCode === keyCodes.ESCAPE || event.code === 'Escape') {
+ onAction({ type: 'cancelMove' });
+ }
+ };
+ const cancelMoveClickListener = (ev: any) => {
+ onAction({ type: 'cancelMove' });
+ };
+ // Give the browser a chance to flush any click events including the click
+ // event that triggered any state transition into selecting a processor to move
+ setTimeout(() => {
+ if (movingProcessor) {
+ window.addEventListener('keyup', cancelMoveKbListener);
+ window.addEventListener('click', cancelMoveClickListener);
+ } else {
+ window.removeEventListener('keyup', cancelMoveKbListener);
+ window.removeEventListener('click', cancelMoveClickListener);
+ }
+ });
+ return () => {
+ window.removeEventListener('keyup', cancelMoveKbListener);
+ window.removeEventListener('click', cancelMoveClickListener);
+ };
+ }, [movingProcessor, onAction]);
+
+ return (
+
+
+
+
+
+
+
+ {
+ onAction({ type: 'addProcessor', payload: { target: baseSelector } });
+ }}
+ />
+
+
+
+
+ );
+});
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/utils.ts
new file mode 100644
index 0000000000000..457e335602b9b
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/utils.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ProcessorInternal } from '../../types';
+
+// These values are tied to the style and heights following components:
+// Do not change these numbers without testing the component for visual
+// regressions!
+// - ./components/tree_node.tsx
+// - ./components/drop_zone_button.tsx
+// - ./components/pipeline_processors_editor_item.tsx
+const itemHeightsPx = {
+ WITHOUT_NESTED_ITEMS: 67,
+ WITH_NESTED_ITEMS: 137,
+ TOP_PADDING: 16,
+};
+
+export const calculateItemHeight = ({
+ processor,
+ isFirstInArray,
+}: {
+ processor: ProcessorInternal;
+ isFirstInArray: boolean;
+}): number => {
+ const padding = isFirstInArray ? itemHeightsPx.TOP_PADDING : 0;
+
+ if (!processor.onFailure?.length) {
+ return padding + itemHeightsPx.WITHOUT_NESTED_ITEMS;
+ }
+
+ return (
+ padding +
+ itemHeightsPx.WITH_NESTED_ITEMS +
+ processor.onFailure.reduce((acc, p, idx) => {
+ return acc + calculateItemHeight({ processor: p, isFirstInArray: idx === 0 });
+ }, 0)
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx
new file mode 100644
index 0000000000000..94d5f0eda6454
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
+
+import React, { FunctionComponent, memo, useEffect } from 'react';
+
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { OnFormUpdateArg } from '../../../../shared_imports';
+
+import { ProcessorInternal } from '../types';
+
+import { ProcessorSettingsForm, ProcessorSettingsFromOnSubmitArg } from '.';
+
+export type OnSubmitHandler = (processor: ProcessorSettingsFromOnSubmitArg) => void;
+
+export interface Props {
+ processor: ProcessorInternal | undefined;
+ onFormUpdate: (form: OnFormUpdateArg) => void;
+ onSubmit: OnSubmitHandler;
+ isOnFailureProcessor: boolean;
+ onOpen: () => void;
+ onClose: () => void;
+}
+
+export const SettingsFormFlyout: FunctionComponent = memo(
+ ({ onClose, processor, onSubmit, onFormUpdate, onOpen, isOnFailureProcessor }) => {
+ useEffect(
+ () => {
+ onOpen();
+ },
+ [] /* eslint-disable-line react-hooks/exhaustive-deps */
+ );
+ const flyoutTitleContent = isOnFailureProcessor ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
+ {flyoutTitleContent}
+
+
+
+
+
+
+ );
+ }
+);
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts
new file mode 100644
index 0000000000000..46e3d1c803fd5
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export enum DropSpecialLocations {
+ top = 'TOP',
+ bottom = 'BOTTOM',
+}
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx
new file mode 100644
index 0000000000000..150a52f1a5fe0
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { createContext, Dispatch, FunctionComponent, useContext, useState } from 'react';
+import { EditorMode } from './types';
+import { ProcessorsDispatch } from './processors_reducer';
+
+interface Links {
+ learnMoreAboutProcessorsUrl: string;
+ learnMoreAboutOnFailureProcessorsUrl: string;
+}
+
+const PipelineProcessorsContext = createContext<{
+ links: Links;
+ state: {
+ processorsDispatch: ProcessorsDispatch;
+ editor: {
+ mode: EditorMode;
+ setMode: Dispatch;
+ };
+ };
+}>({} as any);
+
+interface Props {
+ links: Links;
+ processorsDispatch: ProcessorsDispatch;
+}
+
+export const PipelineProcessorsContextProvider: FunctionComponent = ({
+ links,
+ children,
+ processorsDispatch,
+}) => {
+ const [mode, setMode] = useState({ id: 'idle' });
+ return (
+
+ {children}
+
+ );
+};
+
+export const usePipelineProcessorsContext = () => {
+ const ctx = useContext(PipelineProcessorsContext);
+ if (!ctx) {
+ throw new Error(
+ 'usePipelineProcessorsContext can only be used inside of PipelineProcessorsContextProvider'
+ );
+ }
+ return ctx;
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts
new file mode 100644
index 0000000000000..fa1d041bdaba3
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import uuid from 'uuid';
+import { Processor } from '../../../../common/types';
+import { ProcessorInternal } from './types';
+
+export interface DeserializeArgs {
+ processors: Processor[];
+ onFailure?: Processor[];
+}
+
+export interface DeserializeResult {
+ processors: ProcessorInternal[];
+ onFailure?: ProcessorInternal[];
+}
+
+const getProcessorType = (processor: Processor): string => {
+ /**
+ * See the definition of {@link ProcessorInternal} for why this works to extract the
+ * processor type.
+ */
+ return Object.keys(processor)[0]!;
+};
+
+const convertToPipelineInternalProcessor = (processor: Processor): ProcessorInternal => {
+ const type = getProcessorType(processor);
+ const { on_failure: originalOnFailure, ...options } = processor[type];
+ const onFailure = originalOnFailure?.length
+ ? convertProcessors(originalOnFailure)
+ : (originalOnFailure as ProcessorInternal[] | undefined);
+ return {
+ id: uuid.v4(),
+ type,
+ onFailure,
+ options,
+ };
+};
+
+const convertProcessors = (processors: Processor[]) => {
+ const convertedProcessors = [];
+
+ for (const processor of processors) {
+ convertedProcessors.push(convertToPipelineInternalProcessor(processor));
+ }
+ return convertedProcessors;
+};
+
+export const deserialize = ({ processors, onFailure }: DeserializeArgs): DeserializeResult => {
+ return {
+ processors: convertProcessors(processors),
+ onFailure: onFailure ? convertProcessors(onFailure) : undefined,
+ };
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts
new file mode 100644
index 0000000000000..58d6e492b85e5
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { PipelineProcessorsEditor, OnUpdateHandler } from './pipeline_processors_editor.container';
+
+export { OnUpdateHandlerArg } from './types';
+
+export { SerializeResult } from './serialize';
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx
new file mode 100644
index 0000000000000..057f8638700a4
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx
@@ -0,0 +1,76 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent, useMemo } from 'react';
+
+import { Processor } from '../../../../common/types';
+
+import { deserialize } from './deserialize';
+
+import { useProcessorsState } from './processors_reducer';
+
+import { PipelineProcessorsContextProvider } from './context';
+
+import { OnUpdateHandlerArg } from './types';
+
+import { PipelineProcessorsEditor as PipelineProcessorsEditorUI } from './pipeline_processors_editor';
+
+export interface Props {
+ value: {
+ processors: Processor[];
+ onFailure?: Processor[];
+ };
+ onUpdate: (arg: OnUpdateHandlerArg) => void;
+ isTestButtonDisabled: boolean;
+ onTestPipelineClick: () => void;
+ learnMoreAboutProcessorsUrl: string;
+ learnMoreAboutOnFailureProcessorsUrl: string;
+ /**
+ * Give users a way to react to this component opening a flyout
+ */
+ onFlyoutOpen: () => void;
+}
+
+export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void;
+
+export const PipelineProcessorsEditor: FunctionComponent = ({
+ value: { processors: originalProcessors, onFailure: originalOnFailureProcessors },
+ onFlyoutOpen,
+ onUpdate,
+ isTestButtonDisabled,
+ learnMoreAboutOnFailureProcessorsUrl,
+ learnMoreAboutProcessorsUrl,
+ onTestPipelineClick,
+}) => {
+ const deserializedResult = useMemo(
+ () =>
+ deserialize({
+ processors: originalProcessors,
+ onFailure: originalOnFailureProcessors,
+ }),
+ // TODO: Re-add the dependency on the props and make the state set-able
+ // when new props come in so that this component will be controllable
+ [] // eslint-disable-line react-hooks/exhaustive-deps
+ );
+ const [processorsState, processorsDispatch] = useProcessorsState(deserializedResult);
+ const { processors, onFailure } = processorsState;
+
+ return (
+
+
+
+ );
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss
new file mode 100644
index 0000000000000..ee7421d7dbfa8
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss
@@ -0,0 +1,3 @@
+.pipelineProcessorsEditor {
+ margin-bottom: $euiSize;
+}
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx
new file mode 100644
index 0000000000000..24b9598a74d47
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx
@@ -0,0 +1,239 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FormattedMessage } from '@kbn/i18n/react';
+import React, { FunctionComponent, useCallback, memo, useState, useEffect } from 'react';
+import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiSwitch } from '@elastic/eui';
+
+import './pipeline_processors_editor.scss';
+
+import {
+ ProcessorsTitleAndTestButton,
+ OnFailureProcessorsTitle,
+ ProcessorsTree,
+ SettingsFormFlyout,
+ ProcessorRemoveModal,
+ OnActionHandler,
+ OnSubmitHandler,
+} from './components';
+
+import {
+ ProcessorInternal,
+ ProcessorSelector,
+ OnUpdateHandlerArg,
+ FormValidityState,
+ OnFormUpdateArg,
+} from './types';
+
+import { serialize } from './serialize';
+import { getValue } from './utils';
+import { usePipelineProcessorsContext } from './context';
+
+export interface Props {
+ processors: ProcessorInternal[];
+ onFailureProcessors: ProcessorInternal[];
+ onUpdate: (arg: OnUpdateHandlerArg) => void;
+ isTestButtonDisabled: boolean;
+ onTestPipelineClick: () => void;
+ onFlyoutOpen: () => void;
+}
+
+const PROCESSOR_STATE_SCOPE: ProcessorSelector = ['processors'];
+const ON_FAILURE_STATE_SCOPE: ProcessorSelector = ['onFailure'];
+
+export const PipelineProcessorsEditor: FunctionComponent = memo(
+ function PipelineProcessorsEditor({
+ processors,
+ onFailureProcessors,
+ onTestPipelineClick,
+ isTestButtonDisabled,
+ onUpdate,
+ onFlyoutOpen,
+ }) {
+ const {
+ state: { editor, processorsDispatch },
+ } = usePipelineProcessorsContext();
+
+ const { mode: editorMode, setMode: setEditorMode } = editor;
+
+ const [formState, setFormState] = useState({
+ validate: () => Promise.resolve(true),
+ });
+
+ const onFormUpdate = useCallback<(arg: OnFormUpdateArg) => void>(
+ ({ isValid, validate }) => {
+ setFormState({
+ validate: async () => {
+ if (isValid === undefined) {
+ return validate();
+ }
+ return isValid;
+ },
+ });
+ },
+ [setFormState]
+ );
+
+ const [showGlobalOnFailure, setShowGlobalOnFailure] = useState(
+ Boolean(onFailureProcessors.length)
+ );
+
+ useEffect(() => {
+ onUpdate({
+ validate: async () => {
+ const formValid = await formState.validate();
+ return formValid && editorMode.id === 'idle';
+ },
+ getData: () =>
+ serialize({
+ onFailure: showGlobalOnFailure ? onFailureProcessors : undefined,
+ processors,
+ }),
+ });
+ }, [processors, onFailureProcessors, onUpdate, formState, editorMode, showGlobalOnFailure]);
+
+ const onSubmit = useCallback(
+ (processorTypeAndOptions) => {
+ switch (editorMode.id) {
+ case 'creatingProcessor':
+ processorsDispatch({
+ type: 'addProcessor',
+ payload: {
+ processor: { ...processorTypeAndOptions },
+ targetSelector: editorMode.arg.selector,
+ },
+ });
+ break;
+ case 'editingProcessor':
+ processorsDispatch({
+ type: 'updateProcessor',
+ payload: {
+ processor: {
+ ...editorMode.arg.processor,
+ ...processorTypeAndOptions,
+ },
+ selector: editorMode.arg.selector,
+ },
+ });
+ break;
+ default:
+ }
+ setEditorMode({ id: 'idle' });
+ },
+ [processorsDispatch, editorMode, setEditorMode]
+ );
+
+ const onCloseSettingsForm = useCallback(() => {
+ setEditorMode({ id: 'idle' });
+ setFormState({ validate: () => Promise.resolve(true) });
+ }, [setFormState, setEditorMode]);
+
+ const onTreeAction = useCallback(
+ (action) => {
+ switch (action.type) {
+ case 'addProcessor':
+ setEditorMode({ id: 'creatingProcessor', arg: { selector: action.payload.target } });
+ break;
+ case 'move':
+ setEditorMode({ id: 'idle' });
+ processorsDispatch({
+ type: 'moveProcessor',
+ payload: action.payload,
+ });
+ break;
+ case 'selectToMove':
+ setEditorMode({ id: 'movingProcessor', arg: action.payload.info });
+ break;
+ case 'cancelMove':
+ setEditorMode({ id: 'idle' });
+ break;
+ }
+ },
+ [processorsDispatch, setEditorMode]
+ );
+
+ const movingProcessor = editorMode.id === 'movingProcessor' ? editorMode.arg : undefined;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ checked={showGlobalOnFailure}
+ onChange={(e) => setShowGlobalOnFailure(e.target.checked)}
+ data-test-subj="pipelineEditorOnFailureToggle"
+ />
+
+ {showGlobalOnFailure ? (
+
+
+
+ ) : undefined}
+
+ {editorMode.id === 'editingProcessor' || editorMode.id === 'creatingProcessor' ? (
+
1}
+ processor={editorMode.id === 'editingProcessor' ? editorMode.arg.processor : undefined}
+ onOpen={onFlyoutOpen}
+ onFormUpdate={onFormUpdate}
+ onSubmit={onSubmit}
+ onClose={onCloseSettingsForm}
+ />
+ ) : undefined}
+ {editorMode.id === 'removingProcessor' && (
+ {
+ if (confirmed) {
+ processorsDispatch({
+ type: 'removeProcessor',
+ payload: { selector },
+ });
+ }
+ setEditorMode({ id: 'idle' });
+ }}
+ />
+ )}
+
+ );
+ }
+);
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts
new file mode 100644
index 0000000000000..b43d94e19bf9f
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export {
+ State,
+ reducer,
+ useProcessorsState,
+ ProcessorsDispatch,
+ Action,
+} from './processors_reducer';
+
+export { isChildPath } from './utils';
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts
new file mode 100644
index 0000000000000..43072d65bac4e
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts
@@ -0,0 +1,376 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { reducer, State } from './processors_reducer';
+import { DropSpecialLocations } from '../constants';
+import { PARENT_CHILD_NEST_ERROR } from './utils';
+
+const initialState: State = {
+ processors: [],
+ onFailure: [],
+ isRoot: true,
+};
+
+describe('Processors reducer', () => {
+ it('reorders processors', () => {
+ const processor1 = { id: expect.any(String), type: 'test1', options: {} };
+ const processor2 = { id: expect.any(String), type: 'test2', options: {} };
+ const processor3 = { id: expect.any(String), type: 'test3', options: {} };
+
+ const s1 = reducer(initialState, {
+ type: 'addProcessor',
+ payload: { processor: processor1, targetSelector: ['processors'] },
+ });
+ const s2 = reducer(s1, {
+ type: 'addProcessor',
+ payload: { processor: processor2, targetSelector: ['processors'] },
+ });
+ const s3 = reducer(s2, {
+ type: 'addProcessor',
+ payload: { processor: processor3, targetSelector: ['processors'] },
+ });
+
+ expect(s3.processors).toEqual([processor1, processor2, processor3]);
+
+ // Move the second processor to the first
+ const s4 = reducer(s3, {
+ type: 'moveProcessor',
+ payload: {
+ source: ['processors', '1'],
+ destination: ['processors', '0'],
+ },
+ });
+
+ expect(s4.processors).toEqual([processor2, processor1, processor3]);
+ });
+
+ it('moves and orders processors out of lists', () => {
+ const processor1 = { id: expect.any(String), type: 'test1', options: {} };
+ const processor2 = { id: expect.any(String), type: 'test2', options: {} };
+ const processor3 = { id: expect.any(String), type: 'test3', options: {} };
+ const processor4 = { id: expect.any(String), type: 'test4', options: {} };
+
+ const s1 = reducer(initialState, {
+ type: 'addProcessor',
+ payload: { processor: processor1, targetSelector: ['processors'] },
+ });
+ const s2 = reducer(s1, {
+ type: 'addProcessor',
+ payload: { processor: processor2, targetSelector: ['processors'] },
+ });
+
+ const s3 = reducer(s2, {
+ type: 'addProcessor',
+ payload: { processor: processor3, targetSelector: ['processors', '1'] },
+ });
+
+ const s4 = reducer(s3, {
+ type: 'addProcessor',
+ payload: {
+ processor: processor4,
+ targetSelector: ['processors', '1', 'onFailure', '0'],
+ },
+ });
+
+ expect(s4.processors).toEqual([
+ processor1,
+ { ...processor2, onFailure: [{ ...processor3, onFailure: [processor4] }] },
+ ]);
+
+ // Move the first on failure processor of the second processors on failure processor
+ // to the second position of the root level.
+ const s5 = reducer(s4, {
+ type: 'moveProcessor',
+ payload: {
+ source: ['processors', '1', 'onFailure', '0'],
+ destination: ['processors', '1'],
+ },
+ });
+
+ expect(s5.processors).toEqual([
+ processor1,
+ { ...processor3, onFailure: [processor4] },
+ { ...processor2, onFailure: undefined },
+ ]);
+ });
+
+ it('moves and orders processors into lists', () => {
+ const processor1 = { id: expect.any(String), type: 'test1', options: {} };
+ const processor2 = { id: expect.any(String), type: 'test2', options: {} };
+ const processor3 = { id: expect.any(String), type: 'test3', options: {} };
+ const processor4 = { id: expect.any(String), type: 'test4', options: {} };
+
+ const s1 = reducer(initialState, {
+ type: 'addProcessor',
+ payload: { processor: processor1, targetSelector: ['processors'] },
+ });
+ const s2 = reducer(s1, {
+ type: 'addProcessor',
+ payload: { processor: processor2, targetSelector: ['processors'] },
+ });
+
+ const s3 = reducer(s2, {
+ type: 'addProcessor',
+ payload: { processor: processor3, targetSelector: ['processors', '1'] },
+ });
+
+ const s4 = reducer(s3, {
+ type: 'addProcessor',
+ payload: {
+ processor: processor4,
+ targetSelector: ['processors', '1', 'onFailure', '0'],
+ },
+ });
+
+ expect(s4.processors).toEqual([
+ processor1,
+ { ...processor2, onFailure: [{ ...processor3, onFailure: [processor4] }] },
+ ]);
+
+ // Move the first processor to the deepest most on-failure processor's failure processor
+ const s5 = reducer(s4, {
+ type: 'moveProcessor',
+ payload: {
+ source: ['processors', '0'],
+ destination: ['processors', '1', 'onFailure', '0', 'onFailure', '0', 'onFailure', '0'],
+ },
+ });
+
+ expect(s5.processors).toEqual([
+ {
+ ...processor2,
+ onFailure: [{ ...processor3, onFailure: [{ ...processor4, onFailure: [processor1] }] }],
+ },
+ ]);
+ });
+
+ it('handles sending processor to bottom correctly', () => {
+ const processor1 = { id: expect.any(String), type: 'test1', options: {} };
+ const processor2 = { id: expect.any(String), type: 'test2', options: {} };
+ const processor3 = { id: expect.any(String), type: 'test3', options: {} };
+
+ const s1 = reducer(initialState, {
+ type: 'addProcessor',
+ payload: { processor: processor1, targetSelector: ['processors'] },
+ });
+
+ const s2 = reducer(s1, {
+ type: 'addProcessor',
+ payload: { processor: processor2, targetSelector: ['processors'] },
+ });
+
+ const s3 = reducer(s2, {
+ type: 'addProcessor',
+ payload: { processor: processor3, targetSelector: ['processors'] },
+ });
+
+ // Move the parent into a child list
+ const s4 = reducer(s3, {
+ type: 'moveProcessor',
+ payload: {
+ source: ['processors', '0'],
+ destination: ['processors', DropSpecialLocations.bottom],
+ },
+ });
+
+ // Assert nothing changed
+ expect(s4.processors).toEqual([processor2, processor3, processor1]);
+ });
+
+ it('will not set the root "onFailure" to "undefined" if it is empty', () => {
+ const processor1 = { id: expect.any(String), type: 'test1', options: {} };
+ const processor2 = { id: expect.any(String), type: 'test2', options: {} };
+
+ const s1 = reducer(initialState, {
+ type: 'addProcessor',
+ payload: { processor: processor1, targetSelector: ['processors'] },
+ });
+
+ const s2 = reducer(s1, {
+ type: 'addProcessor',
+ payload: { processor: processor2, targetSelector: ['onFailure'] },
+ });
+
+ // Move the parent into a child list
+ const s3 = reducer(s2, {
+ type: 'moveProcessor',
+ payload: {
+ source: ['onFailure', '0'],
+ destination: ['processors', '1'],
+ },
+ });
+
+ expect(s3).toEqual({
+ processors: [processor1, processor2],
+ onFailure: [],
+ isRoot: true,
+ });
+ });
+
+ it('places copies and places the copied processor below the original', () => {
+ const processor1 = { id: expect.any(String), type: 'test1', options: {} };
+ const processor2 = { id: expect.any(String), type: 'test2', options: {} };
+ const processor3 = { id: expect.any(String), type: 'test3', options: {} };
+ const processor4 = {
+ id: expect.any(String),
+ type: 'test4',
+ options: { field: 'field_name', value: 'field_value' },
+ };
+
+ const s1 = reducer(initialState, {
+ type: 'addProcessor',
+ payload: { processor: processor1, targetSelector: ['processors'] },
+ });
+ const s2 = reducer(s1, {
+ type: 'addProcessor',
+ payload: { processor: processor2, targetSelector: ['processors'] },
+ });
+
+ const s3 = reducer(s2, {
+ type: 'addProcessor',
+ payload: { processor: processor3, targetSelector: ['processors', '1'] },
+ });
+
+ const s4 = reducer(s3, {
+ type: 'addProcessor',
+ payload: {
+ processor: processor4,
+ targetSelector: ['processors', '1', 'onFailure', '0'],
+ },
+ });
+
+ const s5 = reducer(s4, {
+ type: 'duplicateProcessor',
+ payload: { source: ['processors', '1', 'onFailure', '0', 'onFailure', '0'] },
+ });
+
+ const s6 = reducer(s5, {
+ type: 'duplicateProcessor',
+ payload: { source: ['processors', '1', 'onFailure', '0', 'onFailure', '0'] },
+ });
+
+ expect(s6.processors).toEqual([
+ processor1,
+ {
+ ...processor2,
+ onFailure: [
+ {
+ ...processor3,
+ onFailure: [processor4, processor4, processor4],
+ },
+ ],
+ },
+ ]);
+ });
+
+ describe('Error conditions', () => {
+ let originalErrorLogger: any;
+ beforeEach(() => {
+ // eslint-disable-next-line no-console
+ originalErrorLogger = console.error;
+ // eslint-disable-next-line no-console
+ console.error = jest.fn();
+ });
+
+ afterEach(() => {
+ // eslint-disable-next-line no-console
+ console.error = originalErrorLogger;
+ });
+
+ it('prevents moving a parent into child list', () => {
+ const processor1 = { id: expect.any(String), type: 'test1', options: {} };
+ const processor2 = { id: expect.any(String), type: 'test2', options: {} };
+ const processor3 = { id: expect.any(String), type: 'test3', options: {} };
+ const processor4 = { id: expect.any(String), type: 'test4', options: {} };
+
+ const s1 = reducer(initialState, {
+ type: 'addProcessor',
+ payload: { processor: processor1, targetSelector: ['processors'] },
+ });
+
+ const s2 = reducer(s1, {
+ type: 'addProcessor',
+ payload: { processor: processor2, targetSelector: ['processors'] },
+ });
+
+ const s3 = reducer(s2, {
+ type: 'addProcessor',
+ payload: { processor: processor3, targetSelector: ['processors', '1'] },
+ });
+
+ const s4 = reducer(s3, {
+ type: 'addProcessor',
+ payload: {
+ processor: processor4,
+ targetSelector: ['processors', '1', 'onFailure', '0'],
+ },
+ });
+
+ expect(s4.processors).toEqual([
+ processor1,
+ { ...processor2, onFailure: [{ ...processor3, onFailure: [processor4] }] },
+ ]);
+
+ // Move the parent into a child list
+ const s5 = reducer(s4, {
+ type: 'moveProcessor',
+ payload: {
+ source: ['processors', '1'],
+ destination: ['processors', '1', 'onFailure', '0', 'onFailure', '0', 'onFailure', '0'],
+ },
+ });
+
+ // eslint-disable-next-line no-console
+ expect(console.error).toHaveBeenCalledWith(new Error(PARENT_CHILD_NEST_ERROR));
+
+ // Assert nothing changed
+ expect(s5.processors).toEqual(s4.processors);
+ });
+
+ it('does not remove top level processor and onFailure arrays if they are emptied', () => {
+ const processor1 = { id: expect.any(String), type: 'test1', options: {} };
+ const s1 = reducer(initialState, {
+ type: 'addProcessor',
+ payload: { processor: processor1, targetSelector: ['processors'] },
+ });
+ const s2 = reducer(s1, {
+ type: 'removeProcessor',
+ payload: { selector: ['processors', '0'] },
+ });
+ expect(s2.processors).not.toBe(undefined);
+ });
+
+ it('throws for bad move processor', () => {
+ const processor1 = { id: expect.any(String), type: 'test1', options: {} };
+ const processor2 = { id: expect.any(String), type: 'test2', options: {} };
+
+ const s1 = reducer(initialState, {
+ type: 'addProcessor',
+ payload: { processor: processor1, targetSelector: ['processors'] },
+ });
+
+ const s2 = reducer(s1, {
+ type: 'addProcessor',
+ payload: { processor: processor2, targetSelector: ['onFailure'] },
+ });
+
+ const s3 = reducer(s2, {
+ type: 'moveProcessor',
+ payload: {
+ source: ['onFailure'],
+ destination: ['processors'],
+ },
+ });
+
+ // eslint-disable-next-line no-console
+ expect(console.error).toHaveBeenCalledWith(
+ new Error('Expected number but received "processors"')
+ );
+
+ expect(s3.processors).toEqual(s2.processors);
+ });
+ });
+});
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts
new file mode 100644
index 0000000000000..4e069aab8bdd1
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts
@@ -0,0 +1,136 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import uuid from 'uuid';
+import { Reducer, useReducer, Dispatch } from 'react';
+import { DeserializeResult } from '../deserialize';
+import { getValue, setValue } from '../utils';
+import { ProcessorInternal, ProcessorSelector } from '../types';
+
+import { unsafeProcessorMove, duplicateProcessor } from './utils';
+
+export type State = Omit & {
+ onFailure: ProcessorInternal[];
+ isRoot: true;
+};
+
+export type Action =
+ | {
+ type: 'addProcessor';
+ payload: { processor: Omit; targetSelector: ProcessorSelector };
+ }
+ | {
+ type: 'updateProcessor';
+ payload: { processor: ProcessorInternal; selector: ProcessorSelector };
+ }
+ | {
+ type: 'removeProcessor';
+ payload: { selector: ProcessorSelector };
+ }
+ | {
+ type: 'moveProcessor';
+ payload: { source: ProcessorSelector; destination: ProcessorSelector };
+ }
+ | {
+ type: 'duplicateProcessor';
+ payload: {
+ source: ProcessorSelector;
+ };
+ };
+
+export type ProcessorsDispatch = Dispatch;
+
+export const reducer: Reducer = (state, action) => {
+ if (action.type === 'moveProcessor') {
+ const { destination, source } = action.payload;
+ try {
+ return unsafeProcessorMove(state, source, destination);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ return { ...state };
+ }
+ }
+
+ if (action.type === 'removeProcessor') {
+ const { selector } = action.payload;
+ const processorsSelector = selector.slice(0, -1);
+ const parentProcessorSelector = processorsSelector.slice(0, -1);
+ const idx = parseInt(selector[selector.length - 1], 10);
+ const processors = getValue(processorsSelector, state);
+ processors.splice(idx, 1);
+ const parentProcessor = getValue(parentProcessorSelector, state);
+ if (!processors.length && selector.length && !(parentProcessor as State).isRoot) {
+ return setValue(processorsSelector, state, undefined);
+ }
+ return setValue(processorsSelector, state, [...processors]);
+ }
+
+ if (action.type === 'addProcessor') {
+ const { processor, targetSelector } = action.payload;
+ if (!targetSelector.length) {
+ throw new Error('Expected target selector to contain a path, but received an empty array.');
+ }
+ const targetProcessor = getValue(
+ targetSelector,
+ state
+ );
+ if (!targetProcessor) {
+ throw new Error(
+ `Could not find processor or processors array at ${targetSelector.join('.')}`
+ );
+ }
+ if (Array.isArray(targetProcessor)) {
+ return setValue(
+ targetSelector,
+ state,
+ targetProcessor.concat({ ...processor, id: uuid.v4() })
+ );
+ } else {
+ const processorWithId = { ...processor, id: uuid.v4() };
+ targetProcessor.onFailure = targetProcessor.onFailure
+ ? targetProcessor.onFailure.concat(processorWithId)
+ : [processorWithId];
+ return setValue(targetSelector, state, targetProcessor);
+ }
+ }
+
+ if (action.type === 'updateProcessor') {
+ const { processor, selector } = action.payload;
+ const processorsSelector = selector.slice(0, -1);
+ const idx = parseInt(selector[selector.length - 1], 10);
+
+ if (isNaN(idx)) {
+ throw new Error(`Expected numeric value, received ${idx}`);
+ }
+
+ const processors = getValue(processorsSelector, state);
+ processors[idx] = processor;
+ return setValue(processorsSelector, state, [...processors]);
+ }
+
+ if (action.type === 'duplicateProcessor') {
+ const sourceSelector = action.payload.source;
+ const sourceProcessor = getValue(sourceSelector, state);
+ const sourceIdx = parseInt(sourceSelector[sourceSelector.length - 1], 10);
+ const sourceProcessorsArraySelector = sourceSelector.slice(0, -1);
+ const sourceProcessorsArray = [
+ ...getValue(sourceProcessorsArraySelector, state),
+ ];
+ const copy = duplicateProcessor(sourceProcessor);
+ sourceProcessorsArray.splice(sourceIdx + 1, 0, copy);
+ return setValue(sourceProcessorsArraySelector, state, sourceProcessorsArray);
+ }
+
+ return state;
+};
+
+export const useProcessorsState = (initialState: DeserializeResult) => {
+ const state = {
+ ...initialState,
+ onFailure: initialState.onFailure ?? [],
+ };
+ return useReducer(reducer, { ...state, isRoot: true });
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts
new file mode 100644
index 0000000000000..7cb7d076623aa
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts
@@ -0,0 +1,100 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import uuid from 'uuid';
+import { State } from './processors_reducer';
+import { ProcessorInternal, ProcessorSelector } from '../types';
+import { DropSpecialLocations } from '../constants';
+import { checkIfSamePath, getValue } from '../utils';
+
+export const PARENT_CHILD_NEST_ERROR = 'PARENT_CHILD_NEST_ERROR';
+
+export const duplicateProcessor = (sourceProcessor: ProcessorInternal): ProcessorInternal => {
+ const onFailure = sourceProcessor.onFailure
+ ? sourceProcessor.onFailure.map((p) => duplicateProcessor(p))
+ : undefined;
+ return {
+ ...sourceProcessor,
+ onFailure,
+ id: uuid.v4(),
+ options: {
+ ...sourceProcessor.options,
+ },
+ };
+};
+
+export const isChildPath = (a: ProcessorSelector, b: ProcessorSelector) => {
+ return a.every((pathSegment, idx) => pathSegment === b[idx]);
+};
+
+/**
+ * Unsafe!
+ *
+ * This function takes a data structure and mutates it in place.
+ *
+ * It is convenient for updating the processors (see {@link ProcessorInternal})
+ * structure in this way because the structure is recursive. We are moving processors between
+ * different arrays, removing in one, and adding to another. The end result should be consistent
+ * with these actions.
+ *
+ * @remark
+ * This function assumes parents cannot be moved into themselves.
+ */
+export const unsafeProcessorMove = (
+ state: State,
+ source: ProcessorSelector,
+ destination: ProcessorSelector
+): State => {
+ const pathToSourceArray = source.slice(0, -1);
+ const pathToDestArray = destination.slice(0, -1);
+ if (isChildPath(source, destination)) {
+ throw new Error(PARENT_CHILD_NEST_ERROR);
+ }
+ const isXArrayMove = !checkIfSamePath(pathToSourceArray, pathToDestArray);
+
+ // Start by setting up references to objects of interest using our selectors
+ // At this point, our selectors are consistent with the data passed in.
+ const sourceProcessors = getValue(pathToSourceArray, state);
+ const destinationProcessors = getValue(pathToDestArray, state);
+ const sourceIndex = parseInt(source[source.length - 1], 10);
+ const sourceProcessor = getValue(pathToSourceArray.slice(0, -1), state);
+ const processor = sourceProcessors[sourceIndex];
+
+ const lastDestItem = destination[destination.length - 1];
+ let destIndex: number;
+ if (lastDestItem === DropSpecialLocations.top) {
+ destIndex = 0;
+ } else if (lastDestItem === DropSpecialLocations.bottom) {
+ destIndex = Infinity;
+ } else if (/^-?[0-9]+$/.test(lastDestItem)) {
+ destIndex = parseInt(lastDestItem, 10);
+ } else {
+ throw new Error(`Expected number but received "${lastDestItem}"`);
+ }
+
+ if (isXArrayMove) {
+ // First perform the add operation.
+ if (destinationProcessors) {
+ destinationProcessors.splice(destIndex, 0, processor);
+ } else {
+ const targetProcessor = getValue(pathToDestArray.slice(0, -1), state);
+ targetProcessor.onFailure = [processor];
+ }
+ // !! Beyond this point, selectors are no longer usable because we have mutated the data structure!
+ // Second, we perform the deletion operation
+ sourceProcessors.splice(sourceIndex, 1);
+
+ // If onFailure is empty, delete the array.
+ if (!sourceProcessors.length && !((sourceProcessor as unknown) as State).isRoot) {
+ delete sourceProcessor.onFailure;
+ }
+ } else {
+ destinationProcessors.splice(destIndex, 0, processor);
+ const targetIdx = sourceIndex > destIndex ? sourceIndex + 1 : sourceIndex;
+ sourceProcessors.splice(targetIdx, 1);
+ }
+
+ return { ...state, processors: [...state.processors], onFailure: [...state.onFailure] };
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts
new file mode 100644
index 0000000000000..153c9e252ccc0
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { Processor } from '../../../../common/types';
+
+import { DeserializeResult } from './deserialize';
+import { ProcessorInternal } from './types';
+
+type SerializeArgs = DeserializeResult;
+
+export interface SerializeResult {
+ processors: Processor[];
+ on_failure?: Processor[];
+}
+
+const convertProcessorInternalToProcessor = (processor: ProcessorInternal): Processor => {
+ const { options, onFailure, type } = processor;
+ const outProcessor = {
+ [type]: {
+ ...options,
+ },
+ };
+
+ if (onFailure?.length) {
+ outProcessor[type].on_failure = convertProcessors(onFailure);
+ } else if (onFailure) {
+ outProcessor[type].on_failure = [];
+ }
+
+ return outProcessor;
+};
+
+const convertProcessors = (processors: ProcessorInternal[]) => {
+ const convertedProcessors = [];
+
+ for (const processor of processors) {
+ convertedProcessors.push(convertProcessorInternalToProcessor(processor));
+ }
+ return convertedProcessors;
+};
+
+export const serialize = ({ processors, onFailure }: SerializeArgs): SerializeResult => {
+ return {
+ processors: convertProcessors(processors),
+ on_failure: onFailure?.length ? convertProcessors(onFailure) : undefined,
+ };
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts
new file mode 100644
index 0000000000000..aa39fca29fa8b
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { OnFormUpdateArg } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
+import { SerializeResult } from './serialize';
+import { ProcessorInfo } from './components/processors_tree';
+
+/**
+ * An array of keys that map to a value in an object
+ * structure.
+ *
+ * For instance:
+ * ['a', 'b', '0', 'c'] given { a: { b: [ { c: [] } ] } } => []
+ *
+ * Additionally, an empty selector `[]`, is a special indicator
+ * for the root level.
+ */
+export type ProcessorSelector = string[];
+
+/** @private */
+export interface ProcessorInternal {
+ id: string;
+ type: string;
+ options: { [key: string]: any };
+ onFailure?: ProcessorInternal[];
+}
+
+export { OnFormUpdateArg };
+
+export interface FormValidityState {
+ validate: OnFormUpdateArg['validate'];
+}
+
+export interface OnUpdateHandlerArg extends FormValidityState {
+ getData: () => SerializeResult;
+}
+
+/**
+ * The editor can be in different modes. This enables us to hold
+ * a reference to data dispatch to the reducer (like the {@link ProcessorSelector}
+ * which will be used to update the in-memory processors data structure.
+ */
+export type EditorMode =
+ | { id: 'creatingProcessor'; arg: { selector: ProcessorSelector } }
+ | { id: 'movingProcessor'; arg: ProcessorInfo }
+ | { id: 'editingProcessor'; arg: { processor: ProcessorInternal; selector: ProcessorSelector } }
+ | { id: 'removingProcessor'; arg: { selector: ProcessorSelector } }
+ | { id: 'idle' };
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts
new file mode 100644
index 0000000000000..0b7620f517161
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getValue, setValue } from './utils';
+
+describe('get and set values', () => {
+ const testObject = Object.freeze([{ onFailure: [{ onFailure: 1 }] }]);
+ describe('#getValue', () => {
+ it('gets a deeply nested value', () => {
+ expect(getValue(['0', 'onFailure', '0', 'onFailure'], testObject)).toBe(1);
+ });
+
+ it('empty array for path returns "root" value', () => {
+ const result = getValue([], testObject);
+ expect(result).toEqual(testObject);
+ // Getting does not create a copy
+ expect(result).toBe(testObject);
+ });
+ });
+
+ describe('#setValue', () => {
+ it('sets a deeply nested value', () => {
+ const result = setValue(['0', 'onFailure', '0', 'onFailure'], testObject, 2);
+ expect(result).toEqual([{ onFailure: [{ onFailure: 2 }] }]);
+ expect(result).not.toBe(testObject);
+ });
+
+ it('returns value if no path was provided', () => {
+ setValue([], testObject, 2);
+ expect(testObject).toEqual([{ onFailure: [{ onFailure: 1 }] }]);
+ });
+ });
+});
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts
new file mode 100644
index 0000000000000..49d24e8dc35c3
--- /dev/null
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts
@@ -0,0 +1,101 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ProcessorSelector } from './types';
+
+type Path = string[];
+
+/**
+ * The below get and set functions are built with an API to make setting
+ * and getting and setting values more simple.
+ *
+ * @remark
+ * NEVER use these with objects that contain keys created by user input.
+ */
+
+/**
+ * Given a path, get the value at the path
+ *
+ * @remark
+ * If path is an empty array, return the source.
+ */
+export const getValue = (path: Path, source: any) => {
+ let current = source;
+ for (const key of path) {
+ current = (current as any)[key];
+ }
+ return (current as unknown) as Result;
+};
+
+const ARRAY_TYPE = Object.prototype.toString.call([]);
+const OBJECT_TYPE = Object.prototype.toString.call({});
+
+const dumbCopy = (value: R): R => {
+ const objectType = Object.prototype.toString.call(value);
+ if (objectType === ARRAY_TYPE) {
+ return ([...(value as any)] as unknown) as R;
+ } else if (objectType === OBJECT_TYPE) {
+ return { ...(value as any) } as R;
+ }
+
+ throw new Error(`Expected (${ARRAY_TYPE}|${OBJECT_TYPE}) but received ${objectType}`);
+};
+
+const WHITELISTED_KEYS_REGEX = /^([0-9]+|onFailure|processors)$/;
+/**
+ * Given a path, value and an object (array or object) set
+ * the value at the path and copy objects values on the
+ * path only. This is a partial copy mechanism that is best
+ * effort for providing state updates to the UI, could break down
+ * if other updates are made to non-copied parts of state in external
+ * references - but this should not happen.
+ *
+ * @remark
+ * If path is empty, just shallow copy source.
+ */
+export const setValue = (
+ path: Path,
+ source: Target,
+ value: Value
+): Target => {
+ if (!path.length) {
+ return dumbCopy(source);
+ }
+
+ let current: any;
+ let result: Target;
+
+ for (let idx = 0; idx < path.length; ++idx) {
+ const key = path[idx];
+ if (!WHITELISTED_KEYS_REGEX.test(key)) {
+ // eslint-disable-next-line no-console
+ console.error(
+ `Received non-whitelisted key "${key}". Aborting set value operation; returning original.`
+ );
+ return dumbCopy(source);
+ }
+ const atRoot = !current;
+
+ if (atRoot) {
+ result = dumbCopy(source);
+ current = result;
+ }
+
+ if (idx + 1 === path.length) {
+ current[key] = value;
+ } else {
+ current[key] = dumbCopy(current[key]);
+ current = current[key];
+ }
+ }
+
+ return result!;
+};
+
+export const checkIfSamePath = (pathA: ProcessorSelector, pathB: ProcessorSelector) => {
+ if (pathA.length !== pathB.length) return false;
+ return pathA.join('.') === pathB.join('.');
+};
diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx
index 97775965f9b45..0803b419bdbe4 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx
@@ -89,7 +89,7 @@ export const PipelineTable: FunctionComponent = ({
{...reactRouterNavigate(history, '/create')}
>
{i18n.translate('xpack.ingestPipelines.list.table.createPipelineButtonLabel', {
- defaultMessage: 'Create a pipeline here',
+ defaultMessage: 'Create a pipeline',
})}
,
],
diff --git a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts
index ab56ae427120b..9ddb953c71978 100644
--- a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts
+++ b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts
@@ -29,7 +29,13 @@ export {
Form,
getUseField,
ValidationFuncArg,
+ FormData,
+ UseField,
+ FormHook,
useFormContext,
+ FormDataProvider,
+ OnFormUpdateArg,
+ FieldConfig,
} from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
export {
@@ -41,6 +47,9 @@ export {
getFormRow,
Field,
JsonEditorField,
+ FormRow,
+ ToggleField,
+ ComboBoxField,
} from '../../../../src/plugins/es_ui_shared/static/forms/components';
export {
diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json
index a8b22b3e22750..346a5a24c269f 100644
--- a/x-pack/plugins/lens/kibana.json
+++ b/x-pack/plugins/lens/kibana.json
@@ -13,5 +13,6 @@
"dashboard"
],
"optionalPlugins": ["embeddable", "usageCollection", "taskManager", "uiActions"],
- "configPath": ["xpack", "lens"]
+ "configPath": ["xpack", "lens"],
+ "extraPublicDirs": ["common/constants"]
}
diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json
index be28c8e978d8a..6da923c5cff5a 100644
--- a/x-pack/plugins/license_management/kibana.json
+++ b/x-pack/plugins/license_management/kibana.json
@@ -5,5 +5,6 @@
"ui": true,
"requiredPlugins": ["home", "licensing", "management"],
"optionalPlugins": ["telemetry"],
- "configPath": ["xpack", "license_management"]
+ "configPath": ["xpack", "license_management"],
+ "extraPublicDirs": ["common/constants"]
}
diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts
index 38541e205598b..69702a5e8a4f9 100644
--- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts
+++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts
@@ -147,7 +147,7 @@ describe('update_exception_list_item_schema', () => {
// TODO: Is it expected behavior for it not to auto-generate a uui or throw
// error if item_id is not passed in?
- xtest('it should accept an undefined for "item_id" and auto generate a uuid', () => {
+ test.skip('it should accept an undefined for "item_id" and auto generate a uuid', () => {
const inputPayload = getUpdateExceptionListItemSchemaMock();
delete inputPayload.item_id;
const decoded = updateExceptionListItemSchema.decode(inputPayload);
diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.test.ts
index b9d142fbccbee..ff900104251b7 100644
--- a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.test.ts
+++ b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.test.ts
@@ -101,7 +101,7 @@ describe('exception_list_item_schema', () => {
// TODO: Should this throw an error? "namespace_type" gets auto-populated
// with default "single", is that desired behavior?
- xtest('it should NOT accept an undefined for "namespace_type"', () => {
+ test.skip('it should NOT accept an undefined for "namespace_type"', () => {
const payload = getExceptionListItemSchemaMock();
delete payload.namespace_type;
const decoded = exceptionListItemSchema.decode(payload);
diff --git a/x-pack/plugins/lists/common/siem_common_deps.ts b/x-pack/plugins/lists/common/siem_common_deps.ts
index 3759305987f79..b1bb7d8aace36 100644
--- a/x-pack/plugins/lists/common/siem_common_deps.ts
+++ b/x-pack/plugins/lists/common/siem_common_deps.ts
@@ -9,3 +9,5 @@ export { DefaultUuid } from '../../security_solution/common/detection_engine/sch
export { DefaultStringArray } from '../../security_solution/common/detection_engine/schemas/types/default_string_array';
export { exactCheck } from '../../security_solution/common/exact_check';
export { getPaths, foldLeftRight } from '../../security_solution/common/test_utils';
+export { validate } from '../../security_solution/common/validate';
+export { formatErrors } from '../../security_solution/common/format_errors';
diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts
index 2cafd435e0853..375d25c6fa5f8 100644
--- a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
CreateExceptionListItemSchemaDecoded,
createExceptionListItemSchema,
diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_route.ts
index 9be6b72dcd255..bd29a65c9450a 100644
--- a/x-pack/plugins/lists/server/routes/create_exception_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/create_exception_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
CreateExceptionListSchemaDecoded,
createExceptionListSchema,
diff --git a/x-pack/plugins/lists/server/routes/create_list_index_route.ts b/x-pack/plugins/lists/server/routes/create_list_index_route.ts
index 1c893fb757c5d..5ec2b36da61b0 100644
--- a/x-pack/plugins/lists/server/routes/create_list_index_route.ts
+++ b/x-pack/plugins/lists/server/routes/create_list_index_route.ts
@@ -6,7 +6,8 @@
import { IRouter } from 'kibana/server';
-import { buildSiemResponse, transformError, validate } from '../siem_server_deps';
+import { buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { LIST_INDEX } from '../../common/constants';
import { acknowledgeSchema } from '../../common/schemas';
diff --git a/x-pack/plugins/lists/server/routes/create_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_list_item_route.ts
index 68622e98cbc52..2e1b7fa07221f 100644
--- a/x-pack/plugins/lists/server/routes/create_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/create_list_item_route.ts
@@ -7,13 +7,9 @@
import { IRouter } from 'kibana/server';
import { LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
import { createListItemSchema, listItemSchema } from '../../common/schemas';
+import { validate } from '../../common/siem_common_deps';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/create_list_route.ts b/x-pack/plugins/lists/server/routes/create_list_route.ts
index 0f3c404c53cfd..9872bbfa09e23 100644
--- a/x-pack/plugins/lists/server/routes/create_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/create_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { createListSchema, listSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/delete_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/delete_exception_list_item_route.ts
index 2c91fe3c28681..f363252dada50 100644
--- a/x-pack/plugins/lists/server/routes/delete_exception_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/delete_exception_list_item_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
DeleteExceptionListItemSchemaDecoded,
deleteExceptionListItemSchema,
diff --git a/x-pack/plugins/lists/server/routes/delete_exception_list_route.ts b/x-pack/plugins/lists/server/routes/delete_exception_list_route.ts
index b4c67c0ab1418..b1bf705dcc5f6 100644
--- a/x-pack/plugins/lists/server/routes/delete_exception_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/delete_exception_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
DeleteExceptionListSchemaDecoded,
deleteExceptionListSchema,
diff --git a/x-pack/plugins/lists/server/routes/delete_list_index_route.ts b/x-pack/plugins/lists/server/routes/delete_list_index_route.ts
index 424c3f45aac40..cb2e16b3602a7 100644
--- a/x-pack/plugins/lists/server/routes/delete_list_index_route.ts
+++ b/x-pack/plugins/lists/server/routes/delete_list_index_route.ts
@@ -7,7 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_INDEX } from '../../common/constants';
-import { buildSiemResponse, transformError, validate } from '../siem_server_deps';
+import { buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { acknowledgeSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/delete_list_item_route.ts b/x-pack/plugins/lists/server/routes/delete_list_item_route.ts
index 82dfe8a4f29d0..510be764cefba 100644
--- a/x-pack/plugins/lists/server/routes/delete_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/delete_list_item_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { deleteListItemSchema, listItemArraySchema, listItemSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/delete_list_route.ts b/x-pack/plugins/lists/server/routes/delete_list_route.ts
index e89355b7689c5..600e4b00c29ca 100644
--- a/x-pack/plugins/lists/server/routes/delete_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/delete_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { deleteListSchema, listSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts
index 1820ffdeadb88..a6c2a18bb8c8a 100644
--- a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
FindExceptionListItemSchemaDecoded,
findExceptionListItemSchema,
diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_route.ts
index 3181deda8b91d..97e1de834cd37 100644
--- a/x-pack/plugins/lists/server/routes/find_exception_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/find_exception_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
FindExceptionListSchemaDecoded,
findExceptionListSchema,
diff --git a/x-pack/plugins/lists/server/routes/find_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_list_item_route.ts
index 37b5fe44b919c..1ccb948d0ad21 100644
--- a/x-pack/plugins/lists/server/routes/find_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/find_list_item_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { findListItemSchema, foundListItemSchema } from '../../common/schemas';
import { decodeCursor } from '../services/utils';
diff --git a/x-pack/plugins/lists/server/routes/find_list_route.ts b/x-pack/plugins/lists/server/routes/find_list_route.ts
index 04b33e3d67075..2fa43c6368b5c 100644
--- a/x-pack/plugins/lists/server/routes/find_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/find_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { findListSchema, foundListSchema } from '../../common/schemas';
import { decodeCursor } from '../services/utils';
diff --git a/x-pack/plugins/lists/server/routes/import_list_item_route.ts b/x-pack/plugins/lists/server/routes/import_list_item_route.ts
index c951c9b337131..67f345c2c6c1d 100644
--- a/x-pack/plugins/lists/server/routes/import_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/import_list_item_route.ts
@@ -9,12 +9,8 @@ import { Readable } from 'stream';
import { IRouter } from 'kibana/server';
import { LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { importListItemQuerySchema, importListItemSchema, listSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/patch_list_item_route.ts b/x-pack/plugins/lists/server/routes/patch_list_item_route.ts
index e18fd0618b133..f706559dffdbd 100644
--- a/x-pack/plugins/lists/server/routes/patch_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/patch_list_item_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { listItemSchema, patchListItemSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/patch_list_route.ts b/x-pack/plugins/lists/server/routes/patch_list_route.ts
index 9d3fa4db8ccd0..3a0d8714a14cd 100644
--- a/x-pack/plugins/lists/server/routes/patch_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/patch_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { listSchema, patchListSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/read_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/read_exception_list_item_route.ts
index 083d4d7a0d479..c4e969b27fcf4 100644
--- a/x-pack/plugins/lists/server/routes/read_exception_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/read_exception_list_item_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
ReadExceptionListItemSchemaDecoded,
exceptionListItemSchema,
diff --git a/x-pack/plugins/lists/server/routes/read_exception_list_route.ts b/x-pack/plugins/lists/server/routes/read_exception_list_route.ts
index c295f045b38c2..6cb91c10aea55 100644
--- a/x-pack/plugins/lists/server/routes/read_exception_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/read_exception_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
ReadExceptionListSchemaDecoded,
exceptionListSchema,
diff --git a/x-pack/plugins/lists/server/routes/read_list_index_route.ts b/x-pack/plugins/lists/server/routes/read_list_index_route.ts
index 21f539d97fc74..4664bed3e7a8b 100644
--- a/x-pack/plugins/lists/server/routes/read_list_index_route.ts
+++ b/x-pack/plugins/lists/server/routes/read_list_index_route.ts
@@ -7,7 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_INDEX } from '../../common/constants';
-import { buildSiemResponse, transformError, validate } from '../siem_server_deps';
+import { buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { listItemIndexExistSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/read_list_item_route.ts b/x-pack/plugins/lists/server/routes/read_list_item_route.ts
index 10c7f781f554c..24011d3b50d27 100644
--- a/x-pack/plugins/lists/server/routes/read_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/read_list_item_route.ts
@@ -7,13 +7,9 @@
import { IRouter } from 'kibana/server';
import { LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
import { listItemArraySchema, listItemSchema, readListItemSchema } from '../../common/schemas';
+import { validate } from '../../common/siem_common_deps';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/read_list_route.ts b/x-pack/plugins/lists/server/routes/read_list_route.ts
index c30eadfca0b65..34924b70fd4df 100644
--- a/x-pack/plugins/lists/server/routes/read_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/read_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { listSchema, readListSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts
index 73392c326056e..0ec33b7651982 100644
--- a/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
UpdateExceptionListItemSchemaDecoded,
exceptionListItemSchema,
diff --git a/x-pack/plugins/lists/server/routes/update_exception_list_route.ts b/x-pack/plugins/lists/server/routes/update_exception_list_route.ts
index fe45d403c040f..cff78614d05ba 100644
--- a/x-pack/plugins/lists/server/routes/update_exception_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/update_exception_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import {
UpdateExceptionListSchemaDecoded,
exceptionListSchema,
diff --git a/x-pack/plugins/lists/server/routes/update_list_item_route.ts b/x-pack/plugins/lists/server/routes/update_list_item_route.ts
index 494d57b93b8e4..3e231e319104b 100644
--- a/x-pack/plugins/lists/server/routes/update_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/update_list_item_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_ITEM_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { listItemSchema, updateListItemSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/routes/update_list_route.ts b/x-pack/plugins/lists/server/routes/update_list_route.ts
index 6ace61e46a780..a6d9f8329c7c8 100644
--- a/x-pack/plugins/lists/server/routes/update_list_route.ts
+++ b/x-pack/plugins/lists/server/routes/update_list_route.ts
@@ -7,12 +7,8 @@
import { IRouter } from 'kibana/server';
import { LIST_URL } from '../../common/constants';
-import {
- buildRouteValidation,
- buildSiemResponse,
- transformError,
- validate,
-} from '../siem_server_deps';
+import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
+import { validate } from '../../common/siem_common_deps';
import { listSchema, updateListSchema } from '../../common/schemas';
import { getListClient } from '.';
diff --git a/x-pack/plugins/lists/server/siem_server_deps.ts b/x-pack/plugins/lists/server/siem_server_deps.ts
index df4b07fc46322..87a623c7a1892 100644
--- a/x-pack/plugins/lists/server/siem_server_deps.ts
+++ b/x-pack/plugins/lists/server/siem_server_deps.ts
@@ -17,5 +17,4 @@ export {
createBootstrapIndex,
getIndexExists,
buildRouteValidation,
- validate,
} from '../../security_solution/server';
diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json
index 67520321de761..f8a30b8d0337e 100644
--- a/x-pack/plugins/maps/kibana.json
+++ b/x-pack/plugins/maps/kibana.json
@@ -18,5 +18,6 @@
"usageCollection"
],
"ui": true,
- "server": true
+ "server": true,
+ "extraPublicDirs": ["common/constants"]
}
diff --git a/x-pack/plugins/maps/public/api/create_security_layer_descriptors.ts b/x-pack/plugins/maps/public/api/create_security_layer_descriptors.ts
new file mode 100644
index 0000000000000..afff92a584b3c
--- /dev/null
+++ b/x-pack/plugins/maps/public/api/create_security_layer_descriptors.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { LayerDescriptor } from '../../common/descriptor_types';
+import { lazyLoadMapModules } from '../lazy_load_bundle';
+
+export async function createSecurityLayerDescriptors(
+ indexPatternId: string,
+ indexPatternTitle: string
+): Promise {
+ const mapModules = await lazyLoadMapModules();
+ return mapModules.createSecurityLayerDescriptors(indexPatternId, indexPatternTitle);
+}
diff --git a/x-pack/plugins/maps/public/api/index.ts b/x-pack/plugins/maps/public/api/index.ts
new file mode 100644
index 0000000000000..8b45d31b41d44
--- /dev/null
+++ b/x-pack/plugins/maps/public/api/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { MapsStartApi } from './start_api';
diff --git a/x-pack/plugins/maps/public/api/start_api.ts b/x-pack/plugins/maps/public/api/start_api.ts
new file mode 100644
index 0000000000000..d45b0df63c839
--- /dev/null
+++ b/x-pack/plugins/maps/public/api/start_api.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { LayerDescriptor } from '../../common/descriptor_types';
+
+export interface MapsStartApi {
+ createSecurityLayerDescriptors: (
+ indexPatternId: string,
+ indexPatternTitle: string
+ ) => Promise;
+}
diff --git a/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts b/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts
index 8b6f0a0f3f223..8357971a3778f 100644
--- a/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts
+++ b/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts
@@ -25,6 +25,7 @@ import { tmsLayerWizardConfig } from '../sources/xyz_tms_source';
import { wmsLayerWizardConfig } from '../sources/wms_source';
import { mvtVectorSourceWizardConfig } from '../sources/mvt_single_layer_vector_source';
import { ObservabilityLayerWizardConfig } from './solution_layers/observability';
+import { SecurityLayerWizardConfig } from './solution_layers/security';
import { getEnableVectorTiles } from '../../kibana_services';
let registered = false;
@@ -36,6 +37,7 @@ export function registerLayerWizards() {
// Registration order determines display order
registerLayerWizard(uploadLayerWizardConfig);
registerLayerWizard(ObservabilityLayerWizardConfig);
+ registerLayerWizard(SecurityLayerWizardConfig);
// @ts-ignore
registerLayerWizard(esDocumentsLayerWizardConfig);
// @ts-ignore
diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/create_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/create_layer_descriptor.ts
index ba019f97b287f..85601cfc17e8f 100644
--- a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/create_layer_descriptor.ts
+++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/create_layer_descriptor.ts
@@ -40,6 +40,7 @@ import { getDefaultDynamicProperties } from '../../../styles/vector/vector_style
// redefining APM constant to avoid making maps app depend on APM plugin
export const APM_INDEX_PATTERN_ID = 'apm_static_index_pattern_id';
+export const APM_INDEX_PATTERN_TITLE = 'apm-*';
const defaultDynamicProperties = getDefaultDynamicProperties();
@@ -173,7 +174,7 @@ export function createLayerDescriptor({
type: SOURCE_TYPES.ES_TERM_SOURCE,
id: joinId,
indexPatternId: APM_INDEX_PATTERN_ID,
- indexPatternTitle: 'apm-*', // TODO look up from APM_OSS.indexPattern
+ indexPatternTitle: APM_INDEX_PATTERN_TITLE, // TODO look up from APM_OSS.indexPattern
term: 'client.geo.country_iso_code',
metrics: [metricsDescriptor],
whereQuery: apmSourceQuery,
diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/index.ts b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/index.ts
index ae6ade86de980..abe4eccb91e66 100644
--- a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/index.ts
+++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/index.ts
@@ -5,3 +5,4 @@
*/
export { ObservabilityLayerWizardConfig } from './observability_layer_wizard';
+export { APM_INDEX_PATTERN_TITLE } from './create_layer_descriptor';
diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/create_layer_descriptors.test.ts b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/create_layer_descriptors.test.ts
new file mode 100644
index 0000000000000..49a86f45a681b
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/create_layer_descriptors.test.ts
@@ -0,0 +1,688 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+jest.mock('../../../../kibana_services', () => {
+ const mockUiSettings = {
+ get: () => {
+ return undefined;
+ },
+ };
+ return {
+ getUiSettings: () => {
+ return mockUiSettings;
+ },
+ };
+});
+
+jest.mock('uuid/v4', () => {
+ return function () {
+ return '12345';
+ };
+});
+
+import { createSecurityLayerDescriptors } from './create_layer_descriptors';
+
+describe('createLayerDescriptor', () => {
+ test('amp index', () => {
+ expect(createSecurityLayerDescriptors('id', 'apm-*-transaction*')).toEqual([
+ {
+ __dataRequests: [],
+ alpha: 0.75,
+ id: '12345',
+ joins: [],
+ label: 'apm-*-transaction* | Source Point',
+ maxZoom: 24,
+ minZoom: 0,
+ sourceDescriptor: {
+ filterByMapBounds: true,
+ geoField: 'client.geo.location',
+ id: '12345',
+ indexPatternId: 'id',
+ scalingType: 'TOP_HITS',
+ sortField: '',
+ sortOrder: 'desc',
+ tooltipProperties: [
+ 'host.name',
+ 'client.ip',
+ 'client.domain',
+ 'client.geo.country_iso_code',
+ 'client.as.organization.name',
+ ],
+ topHitsSize: 1,
+ topHitsSplitField: 'client.ip',
+ type: 'ES_SEARCH',
+ },
+ style: {
+ isTimeAware: true,
+ properties: {
+ fillColor: {
+ options: {
+ color: '#6092C0',
+ },
+ type: 'STATIC',
+ },
+ icon: {
+ options: {
+ value: 'home',
+ },
+ type: 'STATIC',
+ },
+ iconOrientation: {
+ options: {
+ orientation: 0,
+ },
+ type: 'STATIC',
+ },
+ iconSize: {
+ options: {
+ size: 8,
+ },
+ type: 'STATIC',
+ },
+ labelBorderColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ labelBorderSize: {
+ options: {
+ size: 'SMALL',
+ },
+ },
+ labelColor: {
+ options: {
+ color: '#000000',
+ },
+ type: 'STATIC',
+ },
+ labelSize: {
+ options: {
+ size: 14,
+ },
+ type: 'STATIC',
+ },
+ labelText: {
+ options: {
+ value: '',
+ },
+ type: 'STATIC',
+ },
+ lineColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ lineWidth: {
+ options: {
+ size: 2,
+ },
+ type: 'STATIC',
+ },
+ symbolizeAs: {
+ options: {
+ value: 'icon',
+ },
+ },
+ },
+ type: 'VECTOR',
+ },
+ type: 'VECTOR',
+ visible: true,
+ },
+ {
+ __dataRequests: [],
+ alpha: 0.75,
+ id: '12345',
+ joins: [],
+ label: 'apm-*-transaction* | Destination point',
+ maxZoom: 24,
+ minZoom: 0,
+ sourceDescriptor: {
+ filterByMapBounds: true,
+ geoField: 'server.geo.location',
+ id: '12345',
+ indexPatternId: 'id',
+ scalingType: 'TOP_HITS',
+ sortField: '',
+ sortOrder: 'desc',
+ tooltipProperties: [
+ 'host.name',
+ 'server.ip',
+ 'server.domain',
+ 'server.geo.country_iso_code',
+ 'server.as.organization.name',
+ ],
+ topHitsSize: 1,
+ topHitsSplitField: 'server.ip',
+ type: 'ES_SEARCH',
+ },
+ style: {
+ isTimeAware: true,
+ properties: {
+ fillColor: {
+ options: {
+ color: '#D36086',
+ },
+ type: 'STATIC',
+ },
+ icon: {
+ options: {
+ value: 'marker',
+ },
+ type: 'STATIC',
+ },
+ iconOrientation: {
+ options: {
+ orientation: 0,
+ },
+ type: 'STATIC',
+ },
+ iconSize: {
+ options: {
+ size: 8,
+ },
+ type: 'STATIC',
+ },
+ labelBorderColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ labelBorderSize: {
+ options: {
+ size: 'SMALL',
+ },
+ },
+ labelColor: {
+ options: {
+ color: '#000000',
+ },
+ type: 'STATIC',
+ },
+ labelSize: {
+ options: {
+ size: 14,
+ },
+ type: 'STATIC',
+ },
+ labelText: {
+ options: {
+ value: '',
+ },
+ type: 'STATIC',
+ },
+ lineColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ lineWidth: {
+ options: {
+ size: 2,
+ },
+ type: 'STATIC',
+ },
+ symbolizeAs: {
+ options: {
+ value: 'icon',
+ },
+ },
+ },
+ type: 'VECTOR',
+ },
+ type: 'VECTOR',
+ visible: true,
+ },
+ {
+ __dataRequests: [],
+ alpha: 0.75,
+ id: '12345',
+ joins: [],
+ label: 'apm-*-transaction* | Line',
+ maxZoom: 24,
+ minZoom: 0,
+ sourceDescriptor: {
+ destGeoField: 'server.geo.location',
+ id: '12345',
+ indexPatternId: 'id',
+ metrics: [
+ {
+ field: 'client.bytes',
+ type: 'sum',
+ },
+ {
+ field: 'server.bytes',
+ type: 'sum',
+ },
+ ],
+ sourceGeoField: 'client.geo.location',
+ type: 'ES_PEW_PEW',
+ },
+ style: {
+ isTimeAware: true,
+ properties: {
+ fillColor: {
+ options: {
+ color: '#54B399',
+ },
+ type: 'STATIC',
+ },
+ icon: {
+ options: {
+ value: 'marker',
+ },
+ type: 'STATIC',
+ },
+ iconOrientation: {
+ options: {
+ orientation: 0,
+ },
+ type: 'STATIC',
+ },
+ iconSize: {
+ options: {
+ size: 6,
+ },
+ type: 'STATIC',
+ },
+ labelBorderColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ labelBorderSize: {
+ options: {
+ size: 'SMALL',
+ },
+ },
+ labelColor: {
+ options: {
+ color: '#000000',
+ },
+ type: 'STATIC',
+ },
+ labelSize: {
+ options: {
+ size: 14,
+ },
+ type: 'STATIC',
+ },
+ labelText: {
+ options: {
+ value: '',
+ },
+ type: 'STATIC',
+ },
+ lineColor: {
+ options: {
+ color: '#6092C0',
+ },
+ type: 'STATIC',
+ },
+ lineWidth: {
+ options: {
+ field: {
+ name: 'doc_count',
+ origin: 'source',
+ },
+ fieldMetaOptions: {
+ isEnabled: true,
+ sigma: 3,
+ },
+ maxSize: 8,
+ minSize: 1,
+ },
+ type: 'DYNAMIC',
+ },
+ symbolizeAs: {
+ options: {
+ value: 'circle',
+ },
+ },
+ },
+ type: 'VECTOR',
+ },
+ type: 'VECTOR',
+ visible: true,
+ },
+ ]);
+ });
+
+ test('non-apm index', () => {
+ expect(createSecurityLayerDescriptors('id', 'filebeat-*')).toEqual([
+ {
+ __dataRequests: [],
+ alpha: 0.75,
+ id: '12345',
+ joins: [],
+ label: 'filebeat-* | Source Point',
+ maxZoom: 24,
+ minZoom: 0,
+ sourceDescriptor: {
+ filterByMapBounds: true,
+ geoField: 'source.geo.location',
+ id: '12345',
+ indexPatternId: 'id',
+ scalingType: 'TOP_HITS',
+ sortField: '',
+ sortOrder: 'desc',
+ tooltipProperties: [
+ 'host.name',
+ 'source.ip',
+ 'source.domain',
+ 'source.geo.country_iso_code',
+ 'source.as.organization.name',
+ ],
+ topHitsSize: 1,
+ topHitsSplitField: 'source.ip',
+ type: 'ES_SEARCH',
+ },
+ style: {
+ isTimeAware: true,
+ properties: {
+ fillColor: {
+ options: {
+ color: '#6092C0',
+ },
+ type: 'STATIC',
+ },
+ icon: {
+ options: {
+ value: 'home',
+ },
+ type: 'STATIC',
+ },
+ iconOrientation: {
+ options: {
+ orientation: 0,
+ },
+ type: 'STATIC',
+ },
+ iconSize: {
+ options: {
+ size: 8,
+ },
+ type: 'STATIC',
+ },
+ labelBorderColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ labelBorderSize: {
+ options: {
+ size: 'SMALL',
+ },
+ },
+ labelColor: {
+ options: {
+ color: '#000000',
+ },
+ type: 'STATIC',
+ },
+ labelSize: {
+ options: {
+ size: 14,
+ },
+ type: 'STATIC',
+ },
+ labelText: {
+ options: {
+ value: '',
+ },
+ type: 'STATIC',
+ },
+ lineColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ lineWidth: {
+ options: {
+ size: 2,
+ },
+ type: 'STATIC',
+ },
+ symbolizeAs: {
+ options: {
+ value: 'icon',
+ },
+ },
+ },
+ type: 'VECTOR',
+ },
+ type: 'VECTOR',
+ visible: true,
+ },
+ {
+ __dataRequests: [],
+ alpha: 0.75,
+ id: '12345',
+ joins: [],
+ label: 'filebeat-* | Destination point',
+ maxZoom: 24,
+ minZoom: 0,
+ sourceDescriptor: {
+ filterByMapBounds: true,
+ geoField: 'destination.geo.location',
+ id: '12345',
+ indexPatternId: 'id',
+ scalingType: 'TOP_HITS',
+ sortField: '',
+ sortOrder: 'desc',
+ tooltipProperties: [
+ 'host.name',
+ 'destination.ip',
+ 'destination.domain',
+ 'destination.geo.country_iso_code',
+ 'destination.as.organization.name',
+ ],
+ topHitsSize: 1,
+ topHitsSplitField: 'destination.ip',
+ type: 'ES_SEARCH',
+ },
+ style: {
+ isTimeAware: true,
+ properties: {
+ fillColor: {
+ options: {
+ color: '#D36086',
+ },
+ type: 'STATIC',
+ },
+ icon: {
+ options: {
+ value: 'marker',
+ },
+ type: 'STATIC',
+ },
+ iconOrientation: {
+ options: {
+ orientation: 0,
+ },
+ type: 'STATIC',
+ },
+ iconSize: {
+ options: {
+ size: 8,
+ },
+ type: 'STATIC',
+ },
+ labelBorderColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ labelBorderSize: {
+ options: {
+ size: 'SMALL',
+ },
+ },
+ labelColor: {
+ options: {
+ color: '#000000',
+ },
+ type: 'STATIC',
+ },
+ labelSize: {
+ options: {
+ size: 14,
+ },
+ type: 'STATIC',
+ },
+ labelText: {
+ options: {
+ value: '',
+ },
+ type: 'STATIC',
+ },
+ lineColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ lineWidth: {
+ options: {
+ size: 2,
+ },
+ type: 'STATIC',
+ },
+ symbolizeAs: {
+ options: {
+ value: 'icon',
+ },
+ },
+ },
+ type: 'VECTOR',
+ },
+ type: 'VECTOR',
+ visible: true,
+ },
+ {
+ __dataRequests: [],
+ alpha: 0.75,
+ id: '12345',
+ joins: [],
+ label: 'filebeat-* | Line',
+ maxZoom: 24,
+ minZoom: 0,
+ sourceDescriptor: {
+ destGeoField: 'destination.geo.location',
+ id: '12345',
+ indexPatternId: 'id',
+ metrics: [
+ {
+ field: 'source.bytes',
+ type: 'sum',
+ },
+ {
+ field: 'destination.bytes',
+ type: 'sum',
+ },
+ ],
+ sourceGeoField: 'source.geo.location',
+ type: 'ES_PEW_PEW',
+ },
+ style: {
+ isTimeAware: true,
+ properties: {
+ fillColor: {
+ options: {
+ color: '#54B399',
+ },
+ type: 'STATIC',
+ },
+ icon: {
+ options: {
+ value: 'marker',
+ },
+ type: 'STATIC',
+ },
+ iconOrientation: {
+ options: {
+ orientation: 0,
+ },
+ type: 'STATIC',
+ },
+ iconSize: {
+ options: {
+ size: 6,
+ },
+ type: 'STATIC',
+ },
+ labelBorderColor: {
+ options: {
+ color: '#FFFFFF',
+ },
+ type: 'STATIC',
+ },
+ labelBorderSize: {
+ options: {
+ size: 'SMALL',
+ },
+ },
+ labelColor: {
+ options: {
+ color: '#000000',
+ },
+ type: 'STATIC',
+ },
+ labelSize: {
+ options: {
+ size: 14,
+ },
+ type: 'STATIC',
+ },
+ labelText: {
+ options: {
+ value: '',
+ },
+ type: 'STATIC',
+ },
+ lineColor: {
+ options: {
+ color: '#6092C0',
+ },
+ type: 'STATIC',
+ },
+ lineWidth: {
+ options: {
+ field: {
+ name: 'doc_count',
+ origin: 'source',
+ },
+ fieldMetaOptions: {
+ isEnabled: true,
+ sigma: 3,
+ },
+ maxSize: 8,
+ minSize: 1,
+ },
+ type: 'DYNAMIC',
+ },
+ symbolizeAs: {
+ options: {
+ value: 'circle',
+ },
+ },
+ },
+ type: 'VECTOR',
+ },
+ type: 'VECTOR',
+ visible: true,
+ },
+ ]);
+ });
+});
diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/create_layer_descriptors.ts b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/create_layer_descriptors.ts
new file mode 100644
index 0000000000000..909cd93b3df7a
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/create_layer_descriptors.ts
@@ -0,0 +1,209 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import minimatch from 'minimatch';
+import { i18n } from '@kbn/i18n';
+import { euiPaletteColorBlind } from '@elastic/eui';
+import {
+ LayerDescriptor,
+ SizeDynamicOptions,
+ VectorStylePropertiesDescriptor,
+} from '../../../../../common/descriptor_types';
+import {
+ AGG_TYPE,
+ COUNT_PROP_NAME,
+ FIELD_ORIGIN,
+ SCALING_TYPES,
+ STYLE_TYPE,
+ SYMBOLIZE_AS_TYPES,
+ VECTOR_STYLES,
+} from '../../../../../common/constants';
+import { VectorLayer } from '../../vector_layer/vector_layer';
+import { VectorStyle } from '../../../styles/vector/vector_style';
+// @ts-ignore
+import { ESSearchSource } from '../../../sources/es_search_source';
+// @ts-ignore
+import { ESPewPewSource } from '../../../sources/es_pew_pew_source';
+import { getDefaultDynamicProperties } from '../../../styles/vector/vector_style_defaults';
+import { APM_INDEX_PATTERN_TITLE } from '../observability';
+
+const defaultDynamicProperties = getDefaultDynamicProperties();
+const euiVisColorPalette = euiPaletteColorBlind();
+
+function isApmIndex(indexPatternTitle: string) {
+ return minimatch(indexPatternTitle, APM_INDEX_PATTERN_TITLE);
+}
+
+function getSourceField(indexPatternTitle: string) {
+ return isApmIndex(indexPatternTitle) ? 'client.geo.location' : 'source.geo.location';
+}
+
+function getDestinationField(indexPatternTitle: string) {
+ return isApmIndex(indexPatternTitle) ? 'server.geo.location' : 'destination.geo.location';
+}
+
+function createSourceLayerDescriptor(indexPatternId: string, indexPatternTitle: string) {
+ const sourceDescriptor = ESSearchSource.createDescriptor({
+ indexPatternId,
+ geoField: getSourceField(indexPatternTitle),
+ scalingType: SCALING_TYPES.TOP_HITS,
+ topHitsSplitField: isApmIndex(indexPatternTitle) ? 'client.ip' : 'source.ip',
+ tooltipProperties: isApmIndex(indexPatternTitle)
+ ? [
+ 'host.name',
+ 'client.ip',
+ 'client.domain',
+ 'client.geo.country_iso_code',
+ 'client.as.organization.name',
+ ]
+ : [
+ 'host.name',
+ 'source.ip',
+ 'source.domain',
+ 'source.geo.country_iso_code',
+ 'source.as.organization.name',
+ ],
+ });
+
+ const styleProperties: VectorStylePropertiesDescriptor = {
+ [VECTOR_STYLES.FILL_COLOR]: {
+ type: STYLE_TYPE.STATIC,
+ options: { color: euiVisColorPalette[1] },
+ },
+ [VECTOR_STYLES.LINE_COLOR]: {
+ type: STYLE_TYPE.STATIC,
+ options: { color: '#FFFFFF' },
+ },
+ [VECTOR_STYLES.LINE_WIDTH]: { type: STYLE_TYPE.STATIC, options: { size: 2 } },
+ [VECTOR_STYLES.SYMBOLIZE_AS]: {
+ options: { value: SYMBOLIZE_AS_TYPES.ICON },
+ },
+ [VECTOR_STYLES.ICON]: {
+ type: STYLE_TYPE.STATIC,
+ options: { value: 'home' },
+ },
+ [VECTOR_STYLES.ICON_SIZE]: { type: STYLE_TYPE.STATIC, options: { size: 8 } },
+ };
+
+ return VectorLayer.createDescriptor({
+ label: i18n.translate('xpack.maps.sescurity.sourceLayerLabel', {
+ defaultMessage: '{indexPatternTitle} | Source Point',
+ values: { indexPatternTitle },
+ }),
+ sourceDescriptor,
+ style: VectorStyle.createDescriptor(styleProperties),
+ });
+}
+
+function createDestinationLayerDescriptor(indexPatternId: string, indexPatternTitle: string) {
+ const sourceDescriptor = ESSearchSource.createDescriptor({
+ indexPatternId,
+ geoField: getDestinationField(indexPatternTitle),
+ scalingType: SCALING_TYPES.TOP_HITS,
+ topHitsSplitField: isApmIndex(indexPatternTitle) ? 'server.ip' : 'destination.ip',
+ tooltipProperties: isApmIndex(indexPatternTitle)
+ ? [
+ 'host.name',
+ 'server.ip',
+ 'server.domain',
+ 'server.geo.country_iso_code',
+ 'server.as.organization.name',
+ ]
+ : [
+ 'host.name',
+ 'destination.ip',
+ 'destination.domain',
+ 'destination.geo.country_iso_code',
+ 'destination.as.organization.name',
+ ],
+ });
+
+ const styleProperties: VectorStylePropertiesDescriptor = {
+ [VECTOR_STYLES.FILL_COLOR]: {
+ type: STYLE_TYPE.STATIC,
+ options: { color: euiVisColorPalette[2] },
+ },
+ [VECTOR_STYLES.LINE_COLOR]: {
+ type: STYLE_TYPE.STATIC,
+ options: { color: '#FFFFFF' },
+ },
+ [VECTOR_STYLES.LINE_WIDTH]: { type: STYLE_TYPE.STATIC, options: { size: 2 } },
+ [VECTOR_STYLES.SYMBOLIZE_AS]: {
+ options: { value: SYMBOLIZE_AS_TYPES.ICON },
+ },
+ [VECTOR_STYLES.ICON]: {
+ type: STYLE_TYPE.STATIC,
+ options: { value: 'marker' },
+ },
+ [VECTOR_STYLES.ICON_SIZE]: { type: STYLE_TYPE.STATIC, options: { size: 8 } },
+ };
+
+ return VectorLayer.createDescriptor({
+ label: i18n.translate('xpack.maps.sescurity.destinationLayerLabel', {
+ defaultMessage: '{indexPatternTitle} | Destination point',
+ values: { indexPatternTitle },
+ }),
+ sourceDescriptor,
+ style: VectorStyle.createDescriptor(styleProperties),
+ });
+}
+
+function createLineLayerDescriptor(indexPatternId: string, indexPatternTitle: string) {
+ const sourceDescriptor = ESPewPewSource.createDescriptor({
+ indexPatternId,
+ sourceGeoField: getSourceField(indexPatternTitle),
+ destGeoField: getDestinationField(indexPatternTitle),
+ metrics: [
+ {
+ type: AGG_TYPE.SUM,
+ field: isApmIndex(indexPatternTitle) ? 'client.bytes' : 'source.bytes',
+ },
+ {
+ type: AGG_TYPE.SUM,
+ field: isApmIndex(indexPatternTitle) ? 'server.bytes' : 'destination.bytes',
+ },
+ ],
+ });
+
+ const styleProperties: VectorStylePropertiesDescriptor = {
+ [VECTOR_STYLES.LINE_COLOR]: {
+ type: STYLE_TYPE.STATIC,
+ options: { color: euiVisColorPalette[1] },
+ },
+ [VECTOR_STYLES.LINE_WIDTH]: {
+ type: STYLE_TYPE.DYNAMIC,
+ options: {
+ ...(defaultDynamicProperties[VECTOR_STYLES.LINE_WIDTH]!.options as SizeDynamicOptions),
+ field: {
+ name: COUNT_PROP_NAME,
+ origin: FIELD_ORIGIN.SOURCE,
+ },
+ minSize: 1,
+ maxSize: 8,
+ },
+ },
+ };
+
+ return VectorLayer.createDescriptor({
+ label: i18n.translate('xpack.maps.sescurity.lineLayerLabel', {
+ defaultMessage: '{indexPatternTitle} | Line',
+ values: { indexPatternTitle },
+ }),
+ sourceDescriptor,
+ style: VectorStyle.createDescriptor(styleProperties),
+ });
+}
+
+export function createSecurityLayerDescriptors(
+ indexPatternId: string,
+ indexPatternTitle: string
+): LayerDescriptor[] {
+ return [
+ createSourceLayerDescriptor(indexPatternId, indexPatternTitle),
+ createDestinationLayerDescriptor(indexPatternId, indexPatternTitle),
+ createLineLayerDescriptor(indexPatternId, indexPatternTitle),
+ ];
+}
diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/index.ts b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/index.ts
new file mode 100644
index 0000000000000..f0cc4f26fc1dd
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { createSecurityLayerDescriptors } from './create_layer_descriptors';
+export { SecurityLayerWizardConfig } from './security_layer_wizard';
diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/index_pattern_select.tsx b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/index_pattern_select.tsx
new file mode 100644
index 0000000000000..1e4d9a57a336c
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/index_pattern_select.tsx
@@ -0,0 +1,96 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { ChangeEvent, Component } from 'react';
+import { EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { getSecurityIndexPatterns, IndexPatternMeta } from './security_index_pattern_utils';
+
+interface Props {
+ value: string;
+ onChange: (indexPatternMeta: IndexPatternMeta | null) => void;
+}
+
+interface State {
+ hasLoaded: boolean;
+ options: EuiSelectOption[];
+}
+
+export class IndexPatternSelect extends Component {
+ private _isMounted: boolean = false;
+
+ state = {
+ hasLoaded: false,
+ options: [],
+ };
+
+ componentWillUnmount() {
+ this._isMounted = false;
+ }
+
+ componentDidMount() {
+ this._isMounted = true;
+ this._loadOptions();
+ }
+
+ async _loadOptions() {
+ const indexPatterns = await getSecurityIndexPatterns();
+ if (!this._isMounted) {
+ return;
+ }
+
+ this.setState({
+ hasLoaded: true,
+ options: [
+ { value: '', text: '' },
+ ...indexPatterns.map(({ id, title }: IndexPatternMeta) => {
+ return {
+ value: id,
+ text: title,
+ };
+ }),
+ ],
+ });
+ }
+
+ _onChange = (event: ChangeEvent) => {
+ const targetOption = this.state.options.find(({ value, text }: EuiSelectOption) => {
+ return event.target.value === value;
+ });
+
+ if (event.target.value === '' || !targetOption) {
+ this.props.onChange(null);
+ return;
+ }
+
+ this.props.onChange({
+ // @ts-expect-error - avoid wrong "Property does not exist on type 'never'." compile error
+ id: targetOption.value,
+ // @ts-expect-error - avoid wrong "Property does not exist on type 'never'." compile error
+ title: targetOption.text,
+ });
+ };
+
+ render() {
+ if (!this.state.hasLoaded) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_index_pattern_utils.ts b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_index_pattern_utils.ts
new file mode 100644
index 0000000000000..141b9133505b7
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_index_pattern_utils.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+/* eslint-disable @typescript-eslint/consistent-type-definitions */
+
+import minimatch from 'minimatch';
+import { SimpleSavedObject } from 'src/core/public';
+// eslint-disable-next-line @kbn/eslint/no-restricted-paths
+import { IndexPatternSavedObjectAttrs } from 'src/plugins/data/common/index_patterns/index_patterns/index_patterns';
+import { getIndexPatternService, getUiSettings } from '../../../../kibana_services';
+
+export type IndexPatternMeta = {
+ id: string;
+ title: string;
+};
+
+export async function getSecurityIndexPatterns(): Promise {
+ const uiSettings = getUiSettings();
+ let securityIndexPatternTitles: string[];
+ try {
+ securityIndexPatternTitles = uiSettings.get('securitySolution:defaultIndex');
+ } catch (error) {
+ // UiSettings throws with unreconized configuration setting
+ // siem:defaultIndex configuration setting is not registered if security app is not running
+ return [];
+ }
+
+ const indexPatternCache = await getIndexPatternService().getCache();
+ return indexPatternCache!
+ .filter((savedObject: SimpleSavedObject) => {
+ return (securityIndexPatternTitles as string[]).some((indexPatternTitle) => {
+ // glob matching index pattern title
+ return minimatch(indexPatternTitle, savedObject?.attributes?.title);
+ });
+ })
+ .map((savedObject: SimpleSavedObject) => {
+ return {
+ id: savedObject.id,
+ title: savedObject.attributes.title,
+ };
+ });
+}
diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_template.tsx b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_template.tsx
new file mode 100644
index 0000000000000..eda489c88fda2
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_template.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Component, Fragment } from 'react';
+import { RenderWizardArguments } from '../../layer_wizard_registry';
+import { IndexPatternSelect } from './index_pattern_select';
+import { createSecurityLayerDescriptors } from './create_layer_descriptors';
+import { IndexPatternMeta } from './security_index_pattern_utils';
+
+interface State {
+ indexPatternId: string | null;
+ indexPatternTitle: string | null;
+}
+
+export class SecurityLayerTemplate extends Component {
+ state = {
+ indexPatternId: null,
+ indexPatternTitle: null,
+ };
+
+ _onIndexPatternChange = (indexPatternMeta: IndexPatternMeta | null) => {
+ this.setState(
+ {
+ indexPatternId: indexPatternMeta ? indexPatternMeta.id : null,
+ indexPatternTitle: indexPatternMeta ? indexPatternMeta.title : null,
+ },
+ this._previewLayer
+ );
+ };
+
+ _previewLayer() {
+ if (!this.state.indexPatternId || !this.state.indexPatternTitle) {
+ this.props.previewLayers([]);
+ return;
+ }
+
+ this.props.previewLayers(
+ createSecurityLayerDescriptors(this.state.indexPatternId!, this.state.indexPatternTitle!)
+ );
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx
new file mode 100644
index 0000000000000..cece00fa37350
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx
@@ -0,0 +1,28 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { i18n } from '@kbn/i18n';
+import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry';
+import { getSecurityIndexPatterns } from './security_index_pattern_utils';
+import { SecurityLayerTemplate } from './security_layer_template';
+
+export const SecurityLayerWizardConfig: LayerWizard = {
+ checkVisibility: async () => {
+ const indexPatterns = await getSecurityIndexPatterns();
+ return indexPatterns.length > 0;
+ },
+ description: i18n.translate('xpack.maps.security.desc', {
+ defaultMessage: 'Security layers',
+ }),
+ icon: 'logoSecurity',
+ renderWizard: (renderWizardArguments: RenderWizardArguments) => {
+ return ;
+ },
+ title: i18n.translate('xpack.maps.security.title', {
+ defaultMessage: 'Security',
+ }),
+};
diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js
index 0d15cff032410..fda73bc0f73a0 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js
@@ -26,13 +26,14 @@ export const sourceTitle = i18n.translate('xpack.maps.source.pewPewTitle', {
export class ESPewPewSource extends AbstractESAggSource {
static type = SOURCE_TYPES.ES_PEW_PEW;
- static createDescriptor({ indexPatternId, sourceGeoField, destGeoField }) {
+ static createDescriptor({ indexPatternId, sourceGeoField, destGeoField, metrics }) {
return {
type: ESPewPewSource.type,
id: uuid(),
indexPatternId: indexPatternId,
sourceGeoField,
destGeoField,
+ metrics: metrics ? metrics : [],
};
}
diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx
index 2b1bbfa81c743..b0c50133ceabb 100644
--- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx
+++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx
@@ -7,6 +7,7 @@
import _ from 'lodash';
import React, { Component, Fragment } from 'react';
import { EuiSpacer, EuiCard, EuiIcon } from '@elastic/eui';
+import { EuiLoadingContent } from '@elastic/eui';
import { getLayerWizards, LayerWizard } from '../../../classes/layers/layer_wizard_registry';
interface Props {
@@ -15,6 +16,7 @@ interface Props {
interface State {
layerWizards: LayerWizard[];
+ hasLoadedWizards: boolean;
}
export class LayerWizardSelect extends Component {
@@ -22,6 +24,7 @@ export class LayerWizardSelect extends Component {
state = {
layerWizards: [],
+ hasLoadedWizards: false,
};
componentDidMount() {
@@ -36,11 +39,18 @@ export class LayerWizardSelect extends Component {
async _loadLayerWizards() {
const layerWizards = await getLayerWizards();
if (this._isMounted) {
- this.setState({ layerWizards });
+ this.setState({ layerWizards, hasLoadedWizards: true });
}
}
render() {
+ if (!this.state.hasLoadedWizards) {
+ return (
+
+ } layout="horizontal" />
+
+ );
+ }
return this.state.layerWizards.map((layerWizard: LayerWizard) => {
const icon = layerWizard.icon ? : undefined;
diff --git a/x-pack/plugins/maps/public/index.ts b/x-pack/plugins/maps/public/index.ts
index 9428946bb62e0..6a144e84b05e0 100644
--- a/x-pack/plugins/maps/public/index.ts
+++ b/x-pack/plugins/maps/public/index.ts
@@ -18,3 +18,4 @@ export const plugin: PluginInitializer = (
export { MAP_SAVED_OBJECT_TYPE } from '../common/constants';
export { ITooltipProperty } from './classes/tooltips/tooltip_property';
+export { MapsPluginStart } from './plugin';
diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts
index 92cefd76aa047..152412376fb09 100644
--- a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts
+++ b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts
@@ -36,6 +36,10 @@ interface LazyLoadedMapModules {
initialLayers?: LayerDescriptor[]
) => LayerDescriptor[];
mergeInputWithSavedMap: any;
+ createSecurityLayerDescriptors: (
+ indexPatternId: string,
+ indexPatternTitle: string
+ ) => LayerDescriptor[];
}
export async function lazyLoadMapModules(): Promise {
@@ -56,6 +60,7 @@ export async function lazyLoadMapModules(): Promise {
addLayerWithoutDataSync,
getInitialLayers,
mergeInputWithSavedMap,
+ createSecurityLayerDescriptors,
} = await import('./lazy');
resolve({
@@ -69,6 +74,7 @@ export async function lazyLoadMapModules(): Promise {
addLayerWithoutDataSync,
getInitialLayers,
mergeInputWithSavedMap,
+ createSecurityLayerDescriptors,
});
});
return loadModulesPromise;
diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts
index b650678b3105c..0600b8d5073c6 100644
--- a/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts
+++ b/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts
@@ -16,3 +16,4 @@ export * from '../../actions';
export * from '../../selectors/map_selectors';
export * from '../../angular/get_initial_layers';
export * from '../../embeddable/merge_input_with_saved_map';
+export * from '../../classes/layers/solution_layers/security';
diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts
index 319be46870ebc..e0639c9d0f365 100644
--- a/x-pack/plugins/maps/public/plugin.ts
+++ b/x-pack/plugins/maps/public/plugin.ts
@@ -46,6 +46,8 @@ import { MapEmbeddableFactory } from './embeddable/map_embeddable_factory';
import { EmbeddableSetup } from '../../../../src/plugins/embeddable/public';
import { MapsConfigType, MapsXPackConfig } from '../config';
import { ILicense } from '../../licensing/common/types';
+import { MapsStartApi } from './api';
+import { createSecurityLayerDescriptors } from './api/create_security_layer_descriptors';
export interface MapsPluginSetupDependencies {
inspector: InspectorSetupContract;
@@ -143,7 +145,10 @@ export class MapsPlugin
};
}
- public start(core: CoreStart, plugins: any) {
+ public start(core: CoreStart, plugins: any): MapsStartApi {
bindStartCoreAndPlugins(core, plugins);
+ return {
+ createSecurityLayerDescriptors,
+ };
}
}
diff --git a/x-pack/plugins/ml/common/constants/time_format.ts b/x-pack/plugins/ml/common/constants/time_format.ts
new file mode 100644
index 0000000000000..109dad2d40ac8
--- /dev/null
+++ b/x-pack/plugins/ml/common/constants/time_format.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/index.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/index.ts
index 9c299c628426a..deb41fbb832cd 100644
--- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/index.ts
+++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/index.ts
@@ -10,3 +10,4 @@ export * from './datafeed';
export * from './datafeed_stats';
export * from './combined_job';
export * from './summary_job';
+export * from './model_snapshot';
diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/model_snapshot.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/model_snapshot.ts
new file mode 100644
index 0000000000000..367a16965a90b
--- /dev/null
+++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/model_snapshot.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { JobId } from './job';
+import { ModelSizeStats } from './job_stats';
+
+export interface ModelSnapshot {
+ job_id: JobId;
+ min_version: string;
+ timestamp: number;
+ description: string;
+ snapshot_id: string;
+ snapshot_doc_count: number;
+ model_size_stats: ModelSizeStats;
+ latest_record_time_stamp: number;
+ latest_result_time_stamp: number;
+ retain: boolean;
+}
diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js
index 52d266cde1a2c..a091da6c359d1 100644
--- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js
+++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js
@@ -43,6 +43,7 @@ import {
getLatestDataOrBucketTimestamp,
isTimeSeriesViewJob,
} from '../../../../../common/util/job_utils';
+import { TIME_FORMAT } from '../../../../../common/constants/time_format';
import {
annotation$,
@@ -50,8 +51,6 @@ import {
annotationsRefreshed,
} from '../../../services/annotations_service';
-const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
-
/**
* Table component for rendering the lists of annotations for an ML job.
*/
diff --git a/x-pack/plugins/ml/public/application/components/color_range_legend/index.ts b/x-pack/plugins/ml/public/application/components/color_range_legend/index.ts
index 93a1ec40f1d5e..8c92f47e5aa07 100644
--- a/x-pack/plugins/ml/public/application/components/color_range_legend/index.ts
+++ b/x-pack/plugins/ml/public/application/components/color_range_legend/index.ts
@@ -11,4 +11,5 @@ export {
useColorRange,
COLOR_RANGE,
COLOR_RANGE_SCALE,
+ useCurrentEuiTheme,
} from './use_color_range';
diff --git a/x-pack/plugins/ml/public/application/components/color_range_legend/use_color_range.ts b/x-pack/plugins/ml/public/application/components/color_range_legend/use_color_range.ts
index 1d5f5cf3a0309..f674372da6785 100644
--- a/x-pack/plugins/ml/public/application/components/color_range_legend/use_color_range.ts
+++ b/x-pack/plugins/ml/public/application/components/color_range_legend/use_color_range.ts
@@ -5,7 +5,7 @@
*/
import d3 from 'd3';
-
+import { useMemo } from 'react';
import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json';
import euiThemeDark from '@elastic/eui/dist/eui_theme_dark.json';
@@ -150,7 +150,7 @@ export const useColorRange = (
colorRangeScale = COLOR_RANGE_SCALE.LINEAR,
featureCount = 1
) => {
- const euiTheme = useUiSettings().get('theme:darkMode') ? euiThemeDark : euiThemeLight;
+ const { euiTheme } = useCurrentEuiTheme();
const colorRanges: Record = {
[COLOR_RANGE.BLUE]: [
@@ -186,3 +186,11 @@ export const useColorRange = (
return scaleTypes[colorRangeScale];
};
+
+export function useCurrentEuiTheme() {
+ const uiSettings = useUiSettings();
+ return useMemo(
+ () => ({ euiTheme: uiSettings.get('theme:darkMode') ? euiThemeDark : euiThemeLight }),
+ [uiSettings]
+ );
+}
diff --git a/x-pack/plugins/ml/public/application/components/job_messages/job_messages.tsx b/x-pack/plugins/ml/public/application/components/job_messages/job_messages.tsx
index fd2b7902833a6..798ceae0f0732 100644
--- a/x-pack/plugins/ml/public/application/components/job_messages/job_messages.tsx
+++ b/x-pack/plugins/ml/public/application/components/job_messages/job_messages.tsx
@@ -13,10 +13,9 @@ import { i18n } from '@kbn/i18n';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { JobMessage } from '../../../../common/types/audit_message';
+import { TIME_FORMAT } from '../../../../common/constants/time_format';
import { JobIcon } from '../job_message_icon';
-const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
-
interface JobMessagesProps {
messages: JobMessage[];
loading: boolean;
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx
new file mode 100644
index 0000000000000..8716f8c85f208
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC } from 'react';
+import { i18n } from '@kbn/i18n';
+
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui';
+
+import { COMBINED_JOB_STATE } from '../model_snapshots_table';
+
+interface Props {
+ combinedJobState: COMBINED_JOB_STATE;
+ hideCloseJobModalVisible(): void;
+ forceCloseJob(): void;
+}
+export const CloseJobConfirm: FC = ({
+ combinedJobState,
+ hideCloseJobModalVisible,
+ forceCloseJob,
+}) => {
+ return (
+
+
+
+ {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING && (
+
+ )}
+ {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_STOPPED && (
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/index.ts b/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/index.ts
new file mode 100644
index 0000000000000..195d14160b5e6
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { CloseJobConfirm } from './close_job_confirm';
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx
new file mode 100644
index 0000000000000..1e99fd12a9fa6
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx
@@ -0,0 +1,218 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC, useCallback, useState, useEffect } from 'react';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import {
+ EuiFlyout,
+ EuiFlyoutFooter,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiTitle,
+ EuiFlyoutBody,
+ EuiSpacer,
+ EuiTextArea,
+ EuiFormRow,
+ EuiSwitch,
+ EuiConfirmModal,
+ EuiOverlayMask,
+ EuiCallOut,
+} from '@elastic/eui';
+
+import {
+ ModelSnapshot,
+ CombinedJobWithStats,
+} from '../../../../../common/types/anomaly_detection_jobs';
+import { ml } from '../../../services/ml_api_service';
+import { useNotifications } from '../../../contexts/kibana';
+
+interface Props {
+ snapshot: ModelSnapshot;
+ job: CombinedJobWithStats;
+ closeFlyout(reload: boolean): void;
+}
+
+export const EditModelSnapshotFlyout: FC = ({ snapshot, job, closeFlyout }) => {
+ const { toasts } = useNotifications();
+ const [description, setDescription] = useState(snapshot.description);
+ const [retain, setRetain] = useState(snapshot.retain);
+ const [deleteModalVisible, setDeleteModalVisible] = useState(false);
+ const [isCurrentSnapshot, setIsCurrentSnapshot] = useState(
+ snapshot.snapshot_id === job.model_snapshot_id
+ );
+
+ useEffect(() => {
+ setIsCurrentSnapshot(snapshot.snapshot_id === job.model_snapshot_id);
+ }, [snapshot]);
+
+ const updateSnapshot = useCallback(async () => {
+ try {
+ await ml.updateModelSnapshot(snapshot.job_id, snapshot.snapshot_id, {
+ description,
+ retain,
+ });
+ closeWithReload();
+ } catch (error) {
+ toasts.addError(new Error(error.body.message), {
+ title: i18n.translate('xpack.ml.editModelSnapshotFlyout.saveErrorTitle', {
+ defaultMessage: 'Model snapshot update failed',
+ }),
+ });
+ }
+ }, [retain, description, snapshot]);
+
+ const deleteSnapshot = useCallback(async () => {
+ try {
+ await ml.deleteModelSnapshot(snapshot.job_id, snapshot.snapshot_id);
+ hideDeleteModal();
+ closeWithReload();
+ } catch (error) {
+ toasts.addError(new Error(error.body.message), {
+ title: i18n.translate('xpack.ml.editModelSnapshotFlyout.deleteErrorTitle', {
+ defaultMessage: 'Model snapshot deletion failed',
+ }),
+ });
+ }
+ }, [snapshot]);
+
+ function closeWithReload() {
+ closeFlyout(true);
+ }
+ function closeWithoutReload() {
+ closeFlyout(false);
+ }
+ function showDeleteModal() {
+ setDeleteModalVisible(true);
+ }
+ function hideDeleteModal() {
+ setDeleteModalVisible(false);
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ {isCurrentSnapshot && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+ setDescription(e.target.value)}
+ />
+
+
+
+ setRetain(e.target.checked)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {deleteModalVisible && (
+
+
+
+ )}
+ >
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/index.ts b/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/index.ts
new file mode 100644
index 0000000000000..fcb534620e438
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { EditModelSnapshotFlyout } from './edit_model_snapshot_flyout';
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/index.ts b/x-pack/plugins/ml/public/application/components/model_snapshots/index.ts
new file mode 100644
index 0000000000000..e16d69ea3eb83
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { ModelSnapshotTable } from './model_snapshots_table';
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/model_snapshots_table.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/model_snapshots_table.tsx
new file mode 100644
index 0000000000000..64fdd97903b60
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/model_snapshots_table.tsx
@@ -0,0 +1,267 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC, useEffect, useCallback, useState } from 'react';
+import { i18n } from '@kbn/i18n';
+
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiInMemoryTable,
+ EuiLoadingSpinner,
+ EuiBasicTableColumn,
+ formatDate,
+} from '@elastic/eui';
+
+import { checkPermission } from '../../capabilities/check_capabilities';
+import { EditModelSnapshotFlyout } from './edit_model_snapshot_flyout';
+import { RevertModelSnapshotFlyout } from './revert_model_snapshot_flyout';
+import { ml } from '../../services/ml_api_service';
+import { JOB_STATE, DATAFEED_STATE } from '../../../../common/constants/states';
+import { TIME_FORMAT } from '../../../../common/constants/time_format';
+import { CloseJobConfirm } from './close_job_confirm';
+import {
+ ModelSnapshot,
+ CombinedJobWithStats,
+} from '../../../../common/types/anomaly_detection_jobs';
+
+interface Props {
+ job: CombinedJobWithStats;
+ refreshJobList: () => void;
+}
+
+export enum COMBINED_JOB_STATE {
+ OPEN_AND_RUNNING,
+ OPEN_AND_STOPPED,
+ CLOSED,
+ UNKNOWN,
+}
+
+export const ModelSnapshotTable: FC = ({ job, refreshJobList }) => {
+ const canCreateJob = checkPermission('canCreateJob');
+ const canStartStopDatafeed = checkPermission('canStartStopDatafeed');
+
+ const [snapshots, setSnapshots] = useState([]);
+ const [snapshotsLoaded, setSnapshotsLoaded] = useState(false);
+ const [editSnapshot, setEditSnapshot] = useState(null);
+ const [revertSnapshot, setRevertSnapshot] = useState(null);
+ const [closeJobModalVisible, setCloseJobModalVisible] = useState(null);
+ const [combinedJobState, setCombinedJobState] = useState(null);
+
+ useEffect(() => {
+ loadModelSnapshots();
+ }, []);
+
+ const loadModelSnapshots = useCallback(async () => {
+ const { model_snapshots: ms } = await ml.getModelSnapshots(job.job_id);
+ setSnapshots(ms);
+ setSnapshotsLoaded(true);
+ }, [job]);
+
+ const checkJobIsClosed = useCallback(
+ async (snapshot: ModelSnapshot) => {
+ const state = await getCombinedJobState(job.job_id);
+ if (state === COMBINED_JOB_STATE.UNKNOWN) {
+ // this will only happen if the job has been deleted by another user
+ // between the time the row has been expended and now
+ // eslint-disable-next-line no-console
+ console.error(`Error retrieving state for job ${job.job_id}`);
+ return;
+ }
+
+ setCombinedJobState(state);
+
+ if (state === COMBINED_JOB_STATE.CLOSED) {
+ // show flyout
+ setRevertSnapshot(snapshot);
+ } else {
+ // show close job modal
+ setCloseJobModalVisible(snapshot);
+ }
+ },
+ [job]
+ );
+
+ function hideCloseJobModalVisible() {
+ setCombinedJobState(null);
+ setCloseJobModalVisible(null);
+ }
+
+ const forceCloseJob = useCallback(async () => {
+ await ml.jobs.forceStopAndCloseJob(job.job_id);
+ if (closeJobModalVisible !== null) {
+ const state = await getCombinedJobState(job.job_id);
+ if (state === COMBINED_JOB_STATE.CLOSED) {
+ setRevertSnapshot(closeJobModalVisible);
+ }
+ }
+ hideCloseJobModalVisible();
+ }, [job, closeJobModalVisible]);
+
+ const closeEditFlyout = useCallback((reload: boolean) => {
+ setEditSnapshot(null);
+ if (reload) {
+ loadModelSnapshots();
+ }
+ }, []);
+
+ const closeRevertFlyout = useCallback((reload: boolean) => {
+ setRevertSnapshot(null);
+ if (reload) {
+ loadModelSnapshots();
+ // wait half a second before refreshing the jobs list
+ setTimeout(refreshJobList, 500);
+ }
+ }, []);
+
+ const columns: Array> = [
+ {
+ field: 'snapshot_id',
+ name: i18n.translate('xpack.ml.modelSnapshotTable.id', {
+ defaultMessage: 'ID',
+ }),
+ sortable: true,
+ },
+ {
+ field: 'description',
+ name: i18n.translate('xpack.ml.modelSnapshotTable.description', {
+ defaultMessage: 'Description',
+ }),
+ sortable: true,
+ },
+ {
+ field: 'timestamp',
+ name: i18n.translate('xpack.ml.modelSnapshotTable.time', {
+ defaultMessage: 'Date created',
+ }),
+ dataType: 'date',
+ render: renderDate,
+ sortable: true,
+ },
+ {
+ field: 'latest_record_time_stamp',
+ name: i18n.translate('xpack.ml.modelSnapshotTable.latestTimestamp', {
+ defaultMessage: 'Latest timestamp',
+ }),
+ dataType: 'date',
+ render: renderDate,
+ sortable: true,
+ },
+ {
+ field: 'retain',
+ name: i18n.translate('xpack.ml.modelSnapshotTable.retain', {
+ defaultMessage: 'Retain',
+ }),
+ width: '100px',
+ sortable: true,
+ },
+ {
+ field: '',
+ width: '100px',
+ name: i18n.translate('xpack.ml.modelSnapshotTable.actions', {
+ defaultMessage: 'Actions',
+ }),
+ actions: [
+ {
+ name: i18n.translate('xpack.ml.modelSnapshotTable.actions.revert.name', {
+ defaultMessage: 'Revert',
+ }),
+ description: i18n.translate('xpack.ml.modelSnapshotTable.actions.revert.description', {
+ defaultMessage: 'Revert to this snapshot',
+ }),
+ enabled: () => canCreateJob && canStartStopDatafeed,
+ type: 'icon',
+ icon: 'crosshairs',
+ onClick: checkJobIsClosed,
+ },
+ {
+ name: i18n.translate('xpack.ml.modelSnapshotTable.actions.edit.name', {
+ defaultMessage: 'Edit',
+ }),
+ description: i18n.translate('xpack.ml.modelSnapshotTable.actions.edit.description', {
+ defaultMessage: 'Edit this snapshot',
+ }),
+ enabled: () => canCreateJob,
+ type: 'icon',
+ icon: 'pencil',
+ onClick: setEditSnapshot,
+ },
+ ],
+ },
+ ];
+
+ if (snapshotsLoaded === false) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ {editSnapshot !== null && (
+
+ )}
+
+ {revertSnapshot !== null && (
+
+ )}
+
+ {closeJobModalVisible !== null && combinedJobState !== null && (
+
+ )}
+ >
+ );
+};
+
+function renderDate(date: number) {
+ return formatDate(date, TIME_FORMAT);
+}
+
+async function getCombinedJobState(jobId: string) {
+ const jobs = await ml.jobs.jobs([jobId]);
+
+ if (jobs.length !== 1) {
+ return COMBINED_JOB_STATE.UNKNOWN;
+ }
+
+ if (jobs[0].state !== JOB_STATE.CLOSED) {
+ if (jobs[0].datafeed_config.state !== DATAFEED_STATE.STOPPED) {
+ return COMBINED_JOB_STATE.OPEN_AND_RUNNING;
+ }
+ return COMBINED_JOB_STATE.OPEN_AND_STOPPED;
+ }
+ return COMBINED_JOB_STATE.CLOSED;
+}
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/chart_loader.ts b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/chart_loader.ts
new file mode 100644
index 0000000000000..2da1e914b8139
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/chart_loader.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { MlResultsService } from '../../../services/results_service';
+import { CombinedJobWithStats } from '../../../../../common/types/anomaly_detection_jobs';
+import { getSeverityType } from '../../../../../common/util/anomaly_utils';
+import { Anomaly } from '../../../jobs/new_job/common/results_loader/results_loader';
+import { LineChartPoint } from '../../../jobs/new_job/common/chart_loader/chart_loader';
+
+export function chartLoaderProvider(mlResultsService: MlResultsService) {
+ async function loadEventRateForJob(
+ job: CombinedJobWithStats,
+ bucketSpanMs: number,
+ bars: number
+ ): Promise {
+ const intervalMs = Math.max(
+ Math.floor(
+ (job.data_counts.latest_record_timestamp - job.data_counts.earliest_record_timestamp) / bars
+ ),
+ bucketSpanMs
+ );
+ const resp = await mlResultsService.getEventRateData(
+ job.datafeed_config.indices.join(),
+ job.datafeed_config.query,
+ job.data_description.time_field,
+ job.data_counts.earliest_record_timestamp,
+ job.data_counts.latest_record_timestamp,
+ intervalMs
+ );
+ if (resp.error !== undefined) {
+ throw resp.error;
+ }
+
+ const events = Object.entries(resp.results).map(([time, value]) => ({
+ time: +time,
+ value: value as number,
+ }));
+
+ if (events.length) {
+ // add one extra bucket with a value of 0
+ // so that an extra blank bar gets drawn at the end of the chart
+ // this solves an issue with elastic charts where the rect annotation
+ // never covers the last bar.
+ events.push({ time: events[events.length - 1].time + intervalMs, value: 0 });
+ }
+
+ return events;
+ }
+
+ async function loadAnomalyDataForJob(
+ job: CombinedJobWithStats,
+ bucketSpanMs: number,
+ bars: number
+ ) {
+ const intervalMs = Math.max(
+ Math.floor(
+ (job.data_counts.latest_record_timestamp - job.data_counts.earliest_record_timestamp) / bars
+ ),
+ bucketSpanMs
+ );
+
+ const resp = await mlResultsService.getScoresByBucket(
+ [job.job_id],
+ job.data_counts.earliest_record_timestamp,
+ job.data_counts.latest_record_timestamp,
+ intervalMs,
+ 1
+ );
+
+ const results = resp.results[job.job_id];
+ if (results === undefined) {
+ return [];
+ }
+
+ const anomalies: Record = {};
+ anomalies[0] = Object.entries(results).map(
+ ([time, value]) =>
+ ({ time: +time, value, severity: getSeverityType(value as number) } as Anomaly)
+ );
+ return anomalies;
+ }
+
+ return { loadEventRateForJob, loadAnomalyDataForJob };
+}
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/create_calendar.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/create_calendar.tsx
new file mode 100644
index 0000000000000..937c394e35bc1
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/create_calendar.tsx
@@ -0,0 +1,310 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC, Fragment, useCallback, memo } from 'react';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import moment from 'moment';
+import { XYBrushArea } from '@elastic/charts';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+ EuiFormRow,
+ EuiFieldText,
+ EuiDatePicker,
+ EuiButtonIcon,
+ EuiPanel,
+} from '@elastic/eui';
+
+import { EventRateChart } from '../../../jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart';
+import { Anomaly } from '../../../jobs/new_job/common/results_loader/results_loader';
+import { useCurrentEuiTheme } from '../../../components/color_range_legend';
+import { LineChartPoint } from '../../../jobs/new_job/common/chart_loader/chart_loader';
+
+export interface CalendarEvent {
+ start: moment.Moment | null;
+ end: moment.Moment | null;
+ description: string;
+}
+
+interface Props {
+ calendarEvents: CalendarEvent[];
+ setCalendarEvents: (calendars: CalendarEvent[]) => void;
+ minSelectableTimeStamp: number;
+ maxSelectableTimeStamp: number;
+ eventRateData: LineChartPoint[];
+ anomalies: Anomaly[];
+ chartReady: boolean;
+}
+
+export const CreateCalendar: FC = ({
+ calendarEvents,
+ setCalendarEvents,
+ minSelectableTimeStamp,
+ maxSelectableTimeStamp,
+ eventRateData,
+ anomalies,
+ chartReady,
+}) => {
+ const maxSelectableTimeMoment = moment(maxSelectableTimeStamp);
+ const minSelectableTimeMoment = moment(minSelectableTimeStamp);
+
+ const { euiTheme } = useCurrentEuiTheme();
+
+ const onBrushEnd = useCallback(
+ ({ x }: XYBrushArea) => {
+ if (x && x.length === 2) {
+ const end = x[1] < minSelectableTimeStamp ? null : x[1];
+ if (end !== null) {
+ const start = x[0] < minSelectableTimeStamp ? minSelectableTimeStamp : x[0];
+
+ setCalendarEvents([
+ ...calendarEvents,
+ {
+ start: moment(start),
+ end: moment(end),
+ description: createDefaultEventDescription(calendarEvents.length + 1),
+ },
+ ]);
+ }
+ }
+ },
+ [calendarEvents]
+ );
+
+ const setStartDate = useCallback(
+ (start: moment.Moment | null, index: number) => {
+ const event = calendarEvents[index];
+ if (event === undefined) {
+ setCalendarEvents([
+ ...calendarEvents,
+ { start, end: null, description: createDefaultEventDescription(index) },
+ ]);
+ } else {
+ event.start = start;
+ setCalendarEvents([...calendarEvents]);
+ }
+ },
+ [calendarEvents]
+ );
+
+ const setEndDate = useCallback(
+ (end: moment.Moment | null, index: number) => {
+ const event = calendarEvents[index];
+ if (event === undefined) {
+ setCalendarEvents([
+ ...calendarEvents,
+ { start: null, end, description: createDefaultEventDescription(index) },
+ ]);
+ } else {
+ event.end = end;
+ setCalendarEvents([...calendarEvents]);
+ }
+ },
+ [calendarEvents]
+ );
+
+ const setDescription = useCallback(
+ (description: string, index: number) => {
+ const event = calendarEvents[index];
+ if (event !== undefined) {
+ event.description = description;
+ setCalendarEvents([...calendarEvents]);
+ }
+ },
+ [calendarEvents]
+ );
+
+ const removeCalendarEvent = useCallback(
+ (index: number) => {
+ if (calendarEvents[index] !== undefined) {
+ const ce = [...calendarEvents];
+ ce.splice(index, 1);
+ setCalendarEvents(ce);
+ }
+ },
+ [calendarEvents]
+ );
+
+ return (
+ <>
+
+
+
+
+
+ ({
+ start: c.start!.valueOf(),
+ end: c.end!.valueOf(),
+ }))}
+ onBrushEnd={onBrushEnd}
+ overlayColor={euiTheme.euiColorPrimary}
+ />
+
+
+ {calendarEvents.map((c, i) => (
+
+
+
+
+
+
+
+ setStartDate(d, i)}
+ />
+
+
+
+
+ setEndDate(d, i)}
+ />
+
+
+
+
+
+
+
+ setDescription(e.target.value, i)}
+ />
+
+
+
+
+
+
+ removeCalendarEvent(i)}
+ iconType="trash"
+ aria-label={i18n.translate(
+ 'xpack.ml.revertModelSnapshotFlyout.createCalendar.deleteLabel',
+ {
+ defaultMessage: 'Delete event',
+ }
+ )}
+ />
+
+
+
+
+
+ ))}
+ >
+ );
+};
+
+interface ChartProps {
+ eventRateData: LineChartPoint[];
+ anomalies: Anomaly[];
+ loading: boolean;
+ onBrushEnd(area: XYBrushArea): void;
+ overlayRanges: Array<{ start: number; end: number }>;
+ overlayColor: string;
+}
+
+const Chart: FC = memo(
+ ({ eventRateData, anomalies, loading, onBrushEnd, overlayRanges, overlayColor }) => (
+ ({
+ start: c.start,
+ end: c.end,
+ color: overlayColor,
+ showMarker: false,
+ }))}
+ onBrushEnd={onBrushEnd}
+ />
+ ),
+ (prev: ChartProps, next: ChartProps) => {
+ // only redraw if the calendar ranges have changes
+ return (
+ prev.overlayRanges.length === next.overlayRanges.length &&
+ JSON.stringify(prev.overlayRanges) === JSON.stringify(next.overlayRanges)
+ );
+ }
+);
+
+function filterIncompleteEvents(event: CalendarEvent): event is CalendarEvent {
+ return event.start !== null && event.end !== null;
+}
+
+function createDefaultEventDescription(index: number) {
+ return i18n.translate(
+ 'xpack.ml.revertModelSnapshotFlyout.createCalendar.defaultEventDescription',
+ {
+ defaultMessage: 'Auto created event {index}',
+ values: { index },
+ }
+ );
+}
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/index.ts b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/index.ts
new file mode 100644
index 0000000000000..33adc65a9e327
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { RevertModelSnapshotFlyout } from './revert_model_snapshot_flyout';
diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx
new file mode 100644
index 0000000000000..ad5915b39d521
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx
@@ -0,0 +1,409 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC, useState, useCallback, useMemo, useEffect } from 'react';
+import { i18n } from '@kbn/i18n';
+import {} from 'lodash';
+import { FormattedMessage } from '@kbn/i18n/react';
+import {
+ EuiFlyout,
+ EuiFlyoutHeader,
+ EuiFlyoutFooter,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiTitle,
+ EuiFlyoutBody,
+ EuiSpacer,
+ EuiFormRow,
+ EuiSwitch,
+ EuiConfirmModal,
+ EuiOverlayMask,
+ EuiCallOut,
+ EuiHorizontalRule,
+ EuiSuperSelect,
+ EuiText,
+ formatDate,
+} from '@elastic/eui';
+
+import {
+ ModelSnapshot,
+ CombinedJobWithStats,
+} from '../../../../../common/types/anomaly_detection_jobs';
+import { ml } from '../../../services/ml_api_service';
+import { useNotifications } from '../../../contexts/kibana';
+import { chartLoaderProvider } from './chart_loader';
+import { mlResultsService } from '../../../services/results_service';
+import { LineChartPoint } from '../../../jobs/new_job/common/chart_loader';
+import { EventRateChart } from '../../../jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart';
+import { Anomaly } from '../../../jobs/new_job/common/results_loader/results_loader';
+import { parseInterval } from '../../../../../common/util/parse_interval';
+import { TIME_FORMAT } from '../../../../../common/constants/time_format';
+import { CreateCalendar, CalendarEvent } from './create_calendar';
+
+interface Props {
+ snapshot: ModelSnapshot;
+ snapshots: ModelSnapshot[];
+ job: CombinedJobWithStats;
+ closeFlyout(reload: boolean): void;
+}
+
+export const RevertModelSnapshotFlyout: FC = ({ snapshot, snapshots, job, closeFlyout }) => {
+ const { toasts } = useNotifications();
+ const { loadAnomalyDataForJob, loadEventRateForJob } = useMemo(
+ () => chartLoaderProvider(mlResultsService),
+ []
+ );
+ const [currentSnapshot, setCurrentSnapshot] = useState(snapshot);
+ const [revertModalVisible, setRevertModalVisible] = useState(false);
+ const [replay, setReplay] = useState(false);
+ const [runInRealTime, setRunInRealTime] = useState(false);
+ const [createCalendar, setCreateCalendar] = useState(false);
+ const [calendarEvents, setCalendarEvents] = useState([]);
+ const [calendarEventsValid, setCalendarEventsValid] = useState(true);
+
+ const [eventRateData, setEventRateData] = useState([]);
+ const [anomalies, setAnomalies] = useState([]);
+ const [chartReady, setChartReady] = useState(false);
+ const [applying, setApplying] = useState(false);
+
+ useEffect(() => {
+ createChartData();
+ }, [currentSnapshot]);
+
+ useEffect(() => {
+ const invalid = calendarEvents.some(
+ (c) => c.description === '' || c.end === null || c.start === null
+ );
+ setCalendarEventsValid(invalid === false);
+
+ // a bug in elastic charts selection can
+ // cause duplicate selected areas to be added
+ // dedupe the calendars based on start and end times
+ const calMap = new Map(
+ calendarEvents.map((c) => [`${c.start?.valueOf()}${c.end?.valueOf()}`, c])
+ );
+ const dedupedCalendarEvents = [...calMap.values()];
+
+ if (dedupedCalendarEvents.length < calendarEvents.length) {
+ // deduped list is shorter, we must have removed something.
+ setCalendarEvents(dedupedCalendarEvents);
+ }
+ }, [calendarEvents]);
+
+ const createChartData = useCallback(async () => {
+ const bucketSpanMs = parseInterval(job.analysis_config.bucket_span)!.asMilliseconds();
+ const eventRate = await loadEventRateForJob(job, bucketSpanMs, 100);
+ const anomalyData = await loadAnomalyDataForJob(job, bucketSpanMs, 100);
+ setEventRateData(eventRate);
+ if (anomalyData[0] !== undefined) {
+ setAnomalies(anomalyData[0]);
+ }
+ setChartReady(true);
+ }, [job]);
+
+ function closeWithReload() {
+ closeFlyout(true);
+ }
+ function closeWithoutReload() {
+ closeFlyout(false);
+ }
+
+ function showRevertModal() {
+ setRevertModalVisible(true);
+ }
+ function hideRevertModal() {
+ setRevertModalVisible(false);
+ }
+
+ async function applyRevert() {
+ setApplying(true);
+ const end =
+ replay && runInRealTime === false ? job.data_counts.latest_record_timestamp : undefined;
+ try {
+ const events =
+ replay && createCalendar
+ ? calendarEvents.filter(filterIncompleteEvents).map((c) => ({
+ start: c.start!.valueOf(),
+ end: c.end!.valueOf(),
+ description: c.description,
+ }))
+ : undefined;
+
+ await ml.jobs.revertModelSnapshot(
+ job.job_id,
+ currentSnapshot.snapshot_id,
+ replay,
+ end,
+ events
+ );
+ hideRevertModal();
+ closeWithReload();
+ } catch (error) {
+ setApplying(false);
+ toasts.addError(new Error(error.body.message), {
+ title: i18n.translate('xpack.ml.revertModelSnapshotFlyout.revertErrorTitle', {
+ defaultMessage: 'Model snapshot revert failed',
+ }),
+ });
+ }
+ }
+
+ function onSnapshotChange(ssId: string) {
+ const ss = snapshots.find((s) => s.snapshot_id === ssId);
+ if (ss !== undefined) {
+ setCurrentSnapshot(ss);
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ {currentSnapshot.description}
+
+
+
+ {false && ( // disabled for now
+ <>
+
+
+
+ ({
+ value: s.snapshot_id,
+ inputDisplay: s.snapshot_id,
+ dropdownDisplay: (
+ <>
+ {s.snapshot_id}
+
+ {s.description}
+
+ >
+ ),
+ }))
+ .reverse()}
+ valueOfSelected={currentSnapshot.snapshot_id}
+ onChange={onSnapshotChange}
+ itemLayoutAlign="top"
+ hasDividers
+ />
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setReplay(e.target.checked)}
+ />
+
+
+ {replay && (
+ <>
+
+ setRunInRealTime(e.target.checked)}
+ />
+
+
+
+ setCreateCalendar(e.target.checked)}
+ />
+
+
+ {createCalendar && (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {revertModalVisible && (
+
+
+
+ )}
+ >
+ );
+};
+
+function filterIncompleteEvents(event: CalendarEvent): event is CalendarEvent {
+ return event.start !== null && event.end !== null;
+}
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx
index e437d27372a3e..b6b335afa53f5 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx
@@ -7,29 +7,38 @@
import React, { FC, Fragment } from 'react';
import { EuiCard, EuiHorizontalRule, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+import { useMlKibana } from '../../../../../contexts/kibana';
-function redirectToAnalyticsManagementPage() {
- window.location.href = '#/data_frame_analytics?';
-}
+export const BackToListPanel: FC = () => {
+ const {
+ services: {
+ application: { navigateToUrl },
+ },
+ } = useMlKibana();
-export const BackToListPanel: FC = () => (
-
-
- }
- title={i18n.translate('xpack.ml.dataframe.analytics.create.analyticsListCardTitle', {
- defaultMessage: 'Data Frame Analytics',
- })}
- description={i18n.translate(
- 'xpack.ml.dataframe.analytics.create.analyticsListCardDescription',
- {
- defaultMessage: 'Return to the analytics management page.',
- }
- )}
- onClick={redirectToAnalyticsManagementPage}
- data-test-subj="analyticsWizardCardManagement"
- />
-
-);
+ const redirectToAnalyticsManagementPage = async () => {
+ await navigateToUrl('#/data_frame_analytics?');
+ };
+
+ return (
+
+
+ }
+ title={i18n.translate('xpack.ml.dataframe.analytics.create.analyticsListCardTitle', {
+ defaultMessage: 'Data Frame Analytics',
+ })}
+ description={i18n.translate(
+ 'xpack.ml.dataframe.analytics.create.analyticsListCardDescription',
+ {
+ defaultMessage: 'Return to the analytics management page.',
+ }
+ )}
+ onClick={redirectToAnalyticsManagementPage}
+ data-test-subj="analyticsWizardCardManagement"
+ />
+
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx
index 24e5785c6e808..105eb9f73804d 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx
@@ -74,8 +74,9 @@ export const ExplorationResultsTable: FC = React.memo(
if (jobConfig === undefined || classificationData === undefined) {
return null;
}
+
// if it's a searchBar syntax error leave the table visible so they can try again
- if (status === INDEX_STATUS.ERROR && !errorMessage.includes('parsing_exception')) {
+ if (status === INDEX_STATUS.ERROR && !errorMessage.includes('failed to create query')) {
return (
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx
index 58f8528236bb9..917ab1b0ed1dd 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx
@@ -56,7 +56,7 @@ export const OutlierExploration: FC = React.memo(({ jobId }) =
const { columns, errorMessage, status, tableItems } = outlierData;
// if it's a searchBar syntax error leave the table visible so they can try again
- if (status === INDEX_STATUS.ERROR && !errorMessage.includes('parsing_exception')) {
+ if (status === INDEX_STATUS.ERROR && !errorMessage.includes('failed to create query')) {
return (
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx
index e8b1cd1a5696a..df7dce7217fd4 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx
@@ -360,7 +360,14 @@ export const CloneAction: FC = ({ createAnalyticsForm, item })
defaultMessage: 'Clone job',
});
- const { notifications, savedObjects } = useMlKibana().services;
+ const {
+ services: {
+ application: { navigateToUrl },
+ notifications: { toasts },
+ savedObjects,
+ },
+ } = useMlKibana();
+
const savedObjectsClient = savedObjects.client;
const onClick = async () => {
@@ -385,7 +392,6 @@ export const CloneAction: FC = ({ createAnalyticsForm, item })
sourceIndexId = ip.id;
}
} catch (e) {
- const { toasts } = notifications;
const error = extractErrorMessage(e);
toasts.addDanger(
@@ -401,9 +407,11 @@ export const CloneAction: FC = ({ createAnalyticsForm, item })
}
if (sourceIndexId) {
- window.location.href = `ml#/data_frame_analytics/new_job?index=${encodeURIComponent(
- sourceIndexId
- )}&jobId=${item.config.id}`;
+ await navigateToUrl(
+ `ml#/data_frame_analytics/new_job?index=${encodeURIComponent(sourceIndexId)}&jobId=${
+ item.config.id
+ }`
+ );
}
};
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx
index d20afe93d2b9d..b03a58a02309d 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx
@@ -26,12 +26,20 @@ interface Props {
}
export const SourceSelection: FC = ({ onClose }) => {
- const { uiSettings, savedObjects } = useMlKibana().services;
+ const {
+ services: {
+ application: { navigateToUrl },
+ savedObjects,
+ uiSettings,
+ },
+ } = useMlKibana();
- const onSearchSelected = (id: string, type: string) => {
- window.location.href = `ml#/data_frame_analytics/new_job?${
- type === 'index-pattern' ? 'index' : 'savedSearchId'
- }=${encodeURIComponent(id)}`;
+ const onSearchSelected = async (id: string, type: string) => {
+ await navigateToUrl(
+ `ml#/data_frame_analytics/new_job?${
+ type === 'index-pattern' ? 'index' : 'savedSearchId'
+ }=${encodeURIComponent(id)}`
+ );
};
return (
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js
index 817715dbf6413..2f40941fd20fe 100644
--- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js
+++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js
@@ -21,6 +21,7 @@ import {
import { formatDate, formatNumber } from '@elastic/eui/lib/services/format';
import { FORECAST_REQUEST_STATE } from '../../../../../../../common/constants/states';
+import { TIME_FORMAT } from '../../../../../../../common/constants/time_format';
import { addItemToRecentlyAccessed } from '../../../../../util/recently_accessed';
import { mlForecastService } from '../../../../../services/forecast_service';
import { i18n } from '@kbn/i18n';
@@ -31,7 +32,6 @@ import {
} from '../../../../../../../common/util/job_utils';
const MAX_FORECASTS = 500;
-const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
/**
* Table component for rendering the lists of forecasts run on an ML job.
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/format_values.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/format_values.js
index 9194f7537cf3d..883ddfca70cd7 100644
--- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/format_values.js
+++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/format_values.js
@@ -8,8 +8,8 @@ import numeral from '@elastic/numeral';
import { formatDate } from '@elastic/eui/lib/services/format';
import { roundToDecimalPlace } from '../../../../formatters/round_to_decimal_place';
import { toLocaleString } from '../../../../util/string_utils';
+import { TIME_FORMAT } from '../../../../../../common/constants/time_format';
-const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
const DATA_FORMAT = '0.0 b';
function formatData(txt) {
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js
index 56da4f1e0ff84..9a5cea62cf6ff 100644
--- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js
+++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js
@@ -14,6 +14,7 @@ import { JsonPane } from './json_tab';
import { DatafeedPreviewPane } from './datafeed_preview_tab';
import { AnnotationsTable } from '../../../../components/annotations/annotations_table';
import { AnnotationFlyout } from '../../../../components/annotations/annotation_flyout';
+import { ModelSnapshotTable } from '../../../../components/model_snapshots';
import { ForecastsTable } from './forecasts_table';
import { JobDetailsPane } from './job_details_pane';
import { JobMessagesPane } from './job_messages_pane';
@@ -25,7 +26,7 @@ export class JobDetails extends Component {
this.state = {};
if (this.props.addYourself) {
- this.props.addYourself(props.jobId, this);
+ this.props.addYourself(props.jobId, (j) => this.updateJob(j));
}
}
@@ -33,9 +34,8 @@ export class JobDetails extends Component {
this.props.removeYourself(this.props.jobId);
}
- static getDerivedStateFromProps(props) {
- const { job, loading } = props;
- return { job, loading };
+ updateJob(job) {
+ this.setState({ job });
}
render() {
@@ -64,8 +64,7 @@ export class JobDetails extends Component {
datafeedTimingStats,
} = extractJobDetails(job);
- const { showFullDetails } = this.props;
-
+ const { showFullDetails, refreshJobList } = this.props;
const tabs = [
{
id: 'job-settings',
@@ -175,6 +174,19 @@ export class JobDetails extends Component {
),
});
+
+ tabs.push({
+ id: 'modelSnapshots',
+ 'data-test-subj': 'mlJobListTab-modelSnapshots',
+ name: i18n.translate('xpack.ml.jobsList.jobDetails.tabs.modelSnapshotsLabel', {
+ defaultMessage: 'Model snapshots',
+ }),
+ content: (
+
+
+
+ ),
+ });
}
return (
@@ -191,4 +203,5 @@ JobDetails.propTypes = {
addYourself: PropTypes.func.isRequired,
removeYourself: PropTypes.func.isRequired,
showFullDetails: PropTypes.bool,
+ refreshJobList: PropTypes.func,
};
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js
index 0afaca3ec12e1..23b68551ca0f5 100644
--- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js
+++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js
@@ -15,6 +15,7 @@ import { ResultLinks, actionsMenuContent } from '../job_actions';
import { JobDescription } from './job_description';
import { JobIcon } from '../../../../components/job_message_icon';
import { getJobIdUrl } from '../../../../util/get_job_id_url';
+import { TIME_FORMAT } from '../../../../../../common/constants/time_format';
import { EuiBadge, EuiBasicTable, EuiButtonIcon, EuiLink, EuiScreenReaderOnly } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@@ -22,7 +23,6 @@ import { FormattedMessage } from '@kbn/i18n/react';
const PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [10, 25, 50];
-const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
// 'isManagementTable' bool prop to determine when to configure table for use in Kibana management page
export class JobsList extends Component {
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js
index bb9e532245d6d..a3b6cb39815a3 100644
--- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js
+++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js
@@ -112,6 +112,7 @@ export class JobsListView extends Component {
addYourself={this.addUpdateFunction}
removeYourself={this.removeUpdateFunction}
showFullDetails={this.props.isManagementTable !== true}
+ refreshJobList={this.onRefreshClick}
/>
);
} else {
@@ -121,6 +122,7 @@ export class JobsListView extends Component {
addYourself={this.addUpdateFunction}
removeYourself={this.removeUpdateFunction}
showFullDetails={this.props.isManagementTable !== true}
+ refreshJobList={this.onRefreshClick}
/>
);
}
@@ -143,10 +145,13 @@ export class JobsListView extends Component {
addYourself={this.addUpdateFunction}
removeYourself={this.removeUpdateFunction}
showFullDetails={this.props.isManagementTable !== true}
+ refreshJobList={this.onRefreshClick}
/>
);
}
- this.setState({ itemIdToExpandedRowMap });
+ this.setState({ itemIdToExpandedRowMap }, () => {
+ this.updateFunctions[jobId](job);
+ });
});
})
.catch((error) => {
@@ -254,7 +259,7 @@ export class JobsListView extends Component {
);
Object.keys(this.updateFunctions).forEach((j) => {
- this.updateFunctions[j].setState({ job: fullJobsList[j] });
+ this.updateFunctions[j](fullJobsList[j]);
});
jobs.forEach((job) => {
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector.js
index 55c87bbc90b10..8cf244c3c7d8a 100644
--- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector.js
+++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector.js
@@ -13,8 +13,7 @@ import { EuiDatePicker, EuiFieldText } from '@elastic/eui';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
-
-const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
+import { TIME_FORMAT } from '../../../../../../../common/constants/time_format';
export class TimeRangeSelector extends Component {
constructor(props) {
diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx
index 2fb8ea2820b29..bd6fedd4ba21c 100644
--- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx
+++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx
@@ -5,13 +5,22 @@
*/
import React, { FC } from 'react';
-import { BarSeries, Chart, ScaleType, Settings, TooltipType } from '@elastic/charts';
+import {
+ HistogramBarSeries,
+ Chart,
+ ScaleType,
+ Settings,
+ TooltipType,
+ BrushEndListener,
+ PartialTheme,
+} from '@elastic/charts';
import { Axes } from '../common/axes';
import { LineChartPoint } from '../../../../common/chart_loader';
import { Anomaly } from '../../../../common/results_loader';
import { useChartColors } from '../common/settings';
import { LoadingWrapper } from '../loading_wrapper';
import { Anomalies } from '../common/anomalies';
+import { OverlayRange } from './overlay_range';
interface Props {
eventRateChartData: LineChartPoint[];
@@ -21,6 +30,13 @@ interface Props {
showAxis?: boolean;
loading?: boolean;
fadeChart?: boolean;
+ overlayRanges?: Array<{
+ start: number;
+ end: number;
+ color: string;
+ showMarker?: boolean;
+ }>;
+ onBrushEnd?: BrushEndListener;
}
export const EventRateChart: FC = ({
@@ -31,10 +47,16 @@ export const EventRateChart: FC = ({
showAxis,
loading = false,
fadeChart,
+ overlayRanges,
+ onBrushEnd,
}) => {
const { EVENT_RATE_COLOR_WITH_ANOMALIES, EVENT_RATE_COLOR } = useChartColors();
const barColor = fadeChart ? EVENT_RATE_COLOR_WITH_ANOMALIES : EVENT_RATE_COLOR;
+ const theme: PartialTheme = {
+ scales: { histogramPadding: 0.2 },
+ };
+
return (
= ({
{showAxis === true && }
-
+ {onBrushEnd === undefined ? (
+
+ ) : (
+
+ )}
+
+ {overlayRanges &&
+ overlayRanges.map((range, i) => (
+
+ ))}
+
- = ({
+ overlayKey,
+ eventRateChartData,
+ start,
+ end,
+ color,
+ showMarker = true,
+}) => {
+ const maxHeight = Math.max(...eventRateChartData.map((e) => e.value));
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {formatDate(start, TIME_FORMAT)}
+
+
+ >
+ ) : undefined
+ }
+ />
+ >
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/create_result_callout.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/create_result_callout.tsx
index 9d0cf705aaba6..4602ceeec905f 100644
--- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/create_result_callout.tsx
+++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/create_result_callout.tsx
@@ -67,7 +67,7 @@ export const CreateResultCallout: FC = memo(
color="primary"
fill={false}
aria-label={i18n.translate(
- 'xpack.ml.newJi18n(ob.recognize.jobsCreationFailed.resetButtonAriaLabel',
+ 'xpack.ml.newJob.recognize.jobsCreationFailed.resetButtonAriaLabel',
{ defaultMessage: 'Reset' }
)}
onClick={onReset}
diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js
index a3be479571702..6c0f393c267aa 100644
--- a/x-pack/plugins/ml/public/application/services/job_service.js
+++ b/x-pack/plugins/ml/public/application/services/job_service.js
@@ -13,6 +13,7 @@ import { ml } from './ml_api_service';
import { mlMessageBarService } from '../components/messagebar';
import { isWebUrl } from '../util/url_utils';
import { ML_DATA_PREVIEW_COUNT } from '../../../common/util/job_utils';
+import { TIME_FORMAT } from '../../../common/constants/time_format';
import { parseInterval } from '../../../common/util/parse_interval';
const msgs = mlMessageBarService;
@@ -929,10 +930,8 @@ function createResultsUrlForJobs(jobsList, resultsPage) {
}
}
- const timeFormat = 'YYYY-MM-DD HH:mm:ss';
-
- const fromString = moment(from).format(timeFormat); // Defaults to 'now' if 'from' is undefined
- const toString = moment(to).format(timeFormat); // Defaults to 'now' if 'to' is undefined
+ const fromString = moment(from).format(TIME_FORMAT); // Defaults to 'now' if 'from' is undefined
+ const toString = moment(to).format(TIME_FORMAT); // Defaults to 'now' if 'to' is undefined
const jobIds = jobsList.map((j) => j.id);
return createResultsUrl(jobIds, fromString, toString, resultsPage);
diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts
index 6e3fd08e90e38..fdaa3c2ffe79e 100644
--- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts
+++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts
@@ -23,6 +23,7 @@ import {
CombinedJob,
Detector,
AnalysisConfig,
+ ModelSnapshot,
} from '../../../../common/types/anomaly_detection_jobs';
import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types';
import { FieldRequestConfig } from '../../datavisualizer/index_based/common';
@@ -77,6 +78,11 @@ export interface CardinalityModelPlotHigh {
export type CardinalityValidationResult = SuccessCardinality | CardinalityModelPlotHigh;
export type CardinalityValidationResults = CardinalityValidationResult[];
+export interface GetModelSnapshotsResponse {
+ count: number;
+ model_snapshots: ModelSnapshot[];
+}
+
export function basePath() {
return '/api/ml';
}
@@ -119,6 +125,13 @@ export const ml = {
});
},
+ forceCloseJob({ jobId }: { jobId: string }) {
+ return http({
+ path: `${basePath()}/anomaly_detectors/${jobId}/_close?force=true`,
+ method: 'POST',
+ });
+ },
+
deleteJob({ jobId }: { jobId: string }) {
return http({
path: `${basePath()}/anomaly_detectors/${jobId}`,
@@ -242,6 +255,13 @@ export const ml = {
});
},
+ forceStopDatafeed({ datafeedId }: { datafeedId: string }) {
+ return http({
+ path: `${basePath()}/datafeeds/${datafeedId}/_stop?force=true`,
+ method: 'POST',
+ });
+ },
+
datafeedPreview({ datafeedId }: { datafeedId: string }) {
return http({
path: `${basePath()}/datafeeds/${datafeedId}/_preview`,
@@ -640,6 +660,33 @@ export const ml = {
});
},
+ getModelSnapshots(jobId: string, snapshotId?: string) {
+ return http({
+ path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots${
+ snapshotId !== undefined ? `/${snapshotId}` : ''
+ }`,
+ });
+ },
+
+ updateModelSnapshot(
+ jobId: string,
+ snapshotId: string,
+ body: { description?: string; retain?: boolean }
+ ) {
+ return http({
+ path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}/_update`,
+ method: 'POST',
+ body: JSON.stringify(body),
+ });
+ },
+
+ deleteModelSnapshot(jobId: string, snapshotId: string) {
+ return http({
+ path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}`,
+ method: 'DELETE',
+ });
+ },
+
annotations,
dataFrameAnalytics,
filters,
diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts
index e2569f6217b34..6aa62da3f0768 100644
--- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts
+++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts
@@ -8,7 +8,11 @@ import { http } from '../http_service';
import { basePath } from './index';
import { Dictionary } from '../../../../common/types/common';
-import { MlJobWithTimeRange, MlSummaryJobs } from '../../../../common/types/anomaly_detection_jobs';
+import {
+ MlJobWithTimeRange,
+ MlSummaryJobs,
+ CombinedJobWithStats,
+} from '../../../../common/types/anomaly_detection_jobs';
import { JobMessage } from '../../../../common/types/audit_message';
import { AggFieldNamePair } from '../../../../common/types/fields';
import { ExistingJobsAndGroups } from '../job_service';
@@ -41,7 +45,7 @@ export const jobs = {
jobs(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
- return http({
+ return http({
path: `${basePath()}/jobs/jobs`,
method: 'POST',
body,
@@ -95,6 +99,7 @@ export const jobs = {
body,
});
},
+
closeJobs(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
return http({
@@ -104,6 +109,15 @@ export const jobs = {
});
},
+ forceStopAndCloseJob(jobId: string) {
+ const body = JSON.stringify({ jobId });
+ return http<{ success: boolean }>({
+ path: `${basePath()}/jobs/force_stop_and_close_job`,
+ method: 'POST',
+ body,
+ });
+ },
+
jobAuditMessages(jobId: string, from?: number) {
const jobIdString = jobId !== undefined ? `/${jobId}` : '';
const query = from !== undefined ? { from } : {};
@@ -255,4 +269,19 @@ export const jobs = {
body,
});
},
+
+ revertModelSnapshot(
+ jobId: string,
+ snapshotId: string,
+ replay: boolean,
+ end?: number,
+ calendarEvents?: Array<{ start: number; end: number; description: string }>
+ ) {
+ const body = JSON.stringify({ jobId, snapshotId, replay, end, calendarEvents });
+ return http<{ total: number; categories: Array<{ count?: number; category: Category }> }>({
+ path: `${basePath()}/jobs/revert_model_snapshot`,
+ method: 'POST',
+ body,
+ });
+ },
};
diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js
index 815aa4810ebaa..9069e8078fca6 100644
--- a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js
+++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js
@@ -12,8 +12,7 @@ import { EuiButton, EuiButtonEmpty, EuiInMemoryTable, EuiSpacer } from '@elastic
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
-
-export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
+import { TIME_FORMAT } from '../../../../../../common/constants/time_format';
function DeleteButton({ onClick, canDeleteCalendar }) {
return (
diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/index.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/index.js
index 0910a04051bf4..89bd0235df996 100644
--- a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/index.js
+++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/index.js
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export { EventsTable, TIME_FORMAT } from './events_table';
+export { EventsTable } from './events_table';
diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js
index 8380fd36b458c..d80e248674a8f 100644
--- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js
+++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js
@@ -24,7 +24,7 @@ import {
EuiFlexItem,
} from '@elastic/eui';
import moment from 'moment';
-import { TIME_FORMAT } from '../events_table';
+import { TIME_FORMAT } from '../../../../../../common/constants/time_format';
import { generateTempId } from '../utils';
import { i18n } from '@kbn/i18n';
diff --git a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts b/x-pack/plugins/ml/server/client/elasticsearch_ml.ts
index d5c7882a30d20..07159534e1e2c 100644
--- a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts
+++ b/x-pack/plugins/ml/server/client/elasticsearch_ml.ts
@@ -393,6 +393,17 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
ml.stopDatafeed = ca({
urls: [
+ {
+ fmt: '/_ml/datafeeds/<%=datafeedId%>/_stop?force=<%=force%>',
+ req: {
+ datafeedId: {
+ type: 'string',
+ },
+ force: {
+ type: 'boolean',
+ },
+ },
+ },
{
fmt: '/_ml/datafeeds/<%=datafeedId%>/_stop',
req: {
@@ -823,4 +834,81 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
],
method: 'GET',
});
+
+ ml.modelSnapshots = ca({
+ urls: [
+ {
+ fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>',
+ req: {
+ jobId: {
+ type: 'string',
+ },
+ snapshotId: {
+ type: 'string',
+ },
+ },
+ },
+ {
+ fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots',
+ req: {
+ jobId: {
+ type: 'string',
+ },
+ },
+ },
+ ],
+ method: 'GET',
+ });
+
+ ml.updateModelSnapshot = ca({
+ urls: [
+ {
+ fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>/_update',
+ req: {
+ jobId: {
+ type: 'string',
+ },
+ snapshotId: {
+ type: 'string',
+ },
+ },
+ },
+ ],
+ method: 'POST',
+ needBody: true,
+ });
+
+ ml.deleteModelSnapshot = ca({
+ urls: [
+ {
+ fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>',
+ req: {
+ jobId: {
+ type: 'string',
+ },
+ snapshotId: {
+ type: 'string',
+ },
+ },
+ },
+ ],
+ method: 'DELETE',
+ });
+
+ ml.revertModelSnapshot = ca({
+ urls: [
+ {
+ fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>/_revert',
+ req: {
+ jobId: {
+ type: 'string',
+ },
+ snapshotId: {
+ type: 'string',
+ },
+ },
+ },
+ ],
+ method: 'POST',
+ });
};
diff --git a/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts b/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts
index acb1bed6a37c0..2eec704f1e784 100644
--- a/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts
+++ b/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts
@@ -5,7 +5,7 @@
*/
import { difference } from 'lodash';
-import { IScopedClusterClient } from 'kibana/server';
+import { APICaller } from 'kibana/server';
import { EventManager, CalendarEvent } from './event_manager';
interface BasicCalendar {
@@ -23,16 +23,16 @@ export interface FormCalendar extends BasicCalendar {
}
export class CalendarManager {
- private _client: IScopedClusterClient['callAsCurrentUser'];
- private _eventManager: any;
+ private _callAsCurrentUser: APICaller;
+ private _eventManager: EventManager;
- constructor(client: any) {
- this._client = client;
- this._eventManager = new EventManager(client);
+ constructor(callAsCurrentUser: APICaller) {
+ this._callAsCurrentUser = callAsCurrentUser;
+ this._eventManager = new EventManager(callAsCurrentUser);
}
async getCalendar(calendarId: string) {
- const resp = await this._client('ml.calendars', {
+ const resp = await this._callAsCurrentUser('ml.calendars', {
calendarId,
});
@@ -43,7 +43,7 @@ export class CalendarManager {
}
async getAllCalendars() {
- const calendarsResp = await this._client('ml.calendars');
+ const calendarsResp = await this._callAsCurrentUser('ml.calendars');
const events: CalendarEvent[] = await this._eventManager.getAllEvents();
const calendars: Calendar[] = calendarsResp.calendars;
@@ -74,7 +74,7 @@ export class CalendarManager {
const events = calendar.events;
delete calendar.calendarId;
delete calendar.events;
- await this._client('ml.addCalendar', {
+ await this._callAsCurrentUser('ml.addCalendar', {
calendarId,
body: calendar,
});
@@ -109,7 +109,7 @@ export class CalendarManager {
// add all new jobs
if (jobsToAdd.length) {
- await this._client('ml.addJobToCalendar', {
+ await this._callAsCurrentUser('ml.addJobToCalendar', {
calendarId,
jobId: jobsToAdd.join(','),
});
@@ -117,7 +117,7 @@ export class CalendarManager {
// remove all removed jobs
if (jobsToRemove.length) {
- await this._client('ml.removeJobFromCalendar', {
+ await this._callAsCurrentUser('ml.removeJobFromCalendar', {
calendarId,
jobId: jobsToRemove.join(','),
});
@@ -131,7 +131,7 @@ export class CalendarManager {
// remove all removed events
await Promise.all(
eventsToRemove.map(async (event) => {
- await this._eventManager.deleteEvent(calendarId, event.event_id);
+ await this._eventManager.deleteEvent(calendarId, event.event_id!);
})
);
@@ -140,6 +140,6 @@ export class CalendarManager {
}
async deleteCalendar(calendarId: string) {
- return this._client('ml.deleteCalendar', { calendarId });
+ return this._callAsCurrentUser('ml.deleteCalendar', { calendarId });
}
}
diff --git a/x-pack/plugins/ml/server/models/calendar/event_manager.ts b/x-pack/plugins/ml/server/models/calendar/event_manager.ts
index 41240e2695f6f..02da0718d83ae 100644
--- a/x-pack/plugins/ml/server/models/calendar/event_manager.ts
+++ b/x-pack/plugins/ml/server/models/calendar/event_manager.ts
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { APICaller } from 'kibana/server';
import { GLOBAL_CALENDAR } from '../../../common/constants/calendars';
export interface CalendarEvent {
@@ -15,13 +16,10 @@ export interface CalendarEvent {
}
export class EventManager {
- private _client: any;
- constructor(client: any) {
- this._client = client;
- }
+ constructor(private _callAsCurrentUser: APICaller) {}
async getCalendarEvents(calendarId: string) {
- const resp = await this._client('ml.events', { calendarId });
+ const resp = await this._callAsCurrentUser('ml.events', { calendarId });
return resp.events;
}
@@ -29,7 +27,7 @@ export class EventManager {
// jobId is optional
async getAllEvents(jobId?: string) {
const calendarId = GLOBAL_CALENDAR;
- const resp = await this._client('ml.events', {
+ const resp = await this._callAsCurrentUser('ml.events', {
calendarId,
jobId,
});
@@ -40,14 +38,14 @@ export class EventManager {
async addEvents(calendarId: string, events: CalendarEvent[]) {
const body = { events };
- return await this._client('ml.addEvent', {
+ return await this._callAsCurrentUser('ml.addEvent', {
calendarId,
body,
});
}
async deleteEvent(calendarId: string, eventId: string) {
- return this._client('ml.deleteEvent', { calendarId, eventId });
+ return this._callAsCurrentUser('ml.deleteEvent', { calendarId, eventId });
}
isEqual(ev1: CalendarEvent, ev2: CalendarEvent) {
diff --git a/x-pack/plugins/ml/server/models/calendar/index.ts b/x-pack/plugins/ml/server/models/calendar/index.ts
index 2364c3ac73811..1a35f9f13368e 100644
--- a/x-pack/plugins/ml/server/models/calendar/index.ts
+++ b/x-pack/plugins/ml/server/models/calendar/index.ts
@@ -5,3 +5,4 @@
*/
export { CalendarManager, Calendar, FormCalendar } from './calendar_manager';
+export { CalendarEvent } from './event_manager';
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/kibana/visualization/ml_auditbeat_docker_process_occurrence_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/kibana/visualization/ml_auditbeat_docker_process_occurrence_ecs.json
index 1737039093432..f671d3fa1dec0 100644
--- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/kibana/visualization/ml_auditbeat_docker_process_occurrence_ecs.json
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/kibana/visualization/ml_auditbeat_docker_process_occurrence_ecs.json
@@ -1,6 +1,6 @@
{
"title": "ML Auditbeat Docker: Process Occurrence - experimental (ECS)",
- "visState": "{\"type\":\"vega\",\"params\":{\"spec\":\"{\\n $schema: https://vega.github.io/schema/vega-lite/v2.json\\n mark: {type: \\\"point\\\"}\\n data: {\\n url: {\\n index: \\\"INDEX_PATTERN_NAME\\\"\\n body: {\\n size: 10000\\n query: {\\n bool: {\\n must: [\\n %dashboard_context-must_clause%\\n {\\n exists: {field: \\\"process.executable\\\"}\\n }\\n {\\n function_score: {\\n random_score: {seed: 10, field: \\\"_seq_no\\\"}\\n }\\n }\\n {\\n range: {\\n @timestamp: {\\n %timefilter%: true\\n }\\n }\\n }\\n ]\\n must_not: [\\n \\\"%dashboard_context-must_not_clause%\\\"\\n ]\\n }\\n }\\n script_fields: {\\n process_exe: {\\n script: {source: \\\"params['_source']['process']['executable']\\\"}\\n }\\n }\\n _source: [\\\"@timestamp\\\", \\\"process_exe\\\"]\\n }\\n }\\n format: {property: \\\"hits.hits\\\"}\\n }\\n transform: [\\n {calculate: \\\"toDate(datum._source['@timestamp'])\\\", as: \\\"time\\\"}\\n ]\\n encoding: {\\n x: {\\n field: time\\n type: temporal\\n axis: {labels: true, ticks: true, title: false},\\n timeUnit: utcyearmonthdatehoursminutes\\n }\\n y: {\\n field: fields.process_exe\\n type: ordinal\\n sort: {op: \\\"count\\\", order: \\\"descending\\\"}\\n axis: {labels: true, title: \\\"occurrence of process.executable\\\", ticks: false}\\n }\\n }\\n config: {\\n style: {\\n point: {filled: true}\\n }\\n }\\n}\"},\"aggs\":[]}",
+ "visState": "{\"type\":\"vega\",\"params\":{\"spec\":\"{\\n $schema: https://vega.github.io/schema/vega-lite/v4.json\\n width: \\\"container\\\"\\n mark: {type: \\\"point\\\"}\\n data: {\\n url: {\\n index: \\\"INDEX_PATTERN_NAME\\\"\\n body: {\\n size: 10000\\n query: {\\n bool: {\\n must: [\\n %dashboard_context-must_clause%\\n {\\n exists: {field: \\\"process.executable\\\"}\\n }\\n {\\n function_score: {\\n random_score: {seed: 10, field: \\\"_seq_no\\\"}\\n }\\n }\\n {\\n range: {\\n @timestamp: {\\n %timefilter%: true\\n }\\n }\\n }\\n ]\\n must_not: [\\n \\\"%dashboard_context-must_not_clause%\\\"\\n ]\\n }\\n }\\n script_fields: {\\n process_exe: {\\n script: {source: \\\"params['_source']['process']['executable']\\\"}\\n }\\n }\\n _source: [\\\"@timestamp\\\", \\\"process_exe\\\"]\\n }\\n }\\n format: {property: \\\"hits.hits\\\"}\\n }\\n transform: [\\n {calculate: \\\"toDate(datum._source['@timestamp'])\\\", as: \\\"time\\\"}\\n ]\\n encoding: {\\n x: {\\n field: time\\n type: temporal\\n axis: {labels: true, ticks: true, title: false},\\n timeUnit: utcyearmonthdatehoursminutes\\n }\\n y: {\\n field: fields.process_exe\\n type: ordinal\\n sort: {op: \\\"count\\\", order: \\\"descending\\\"}\\n axis: {labels: true, title: \\\"occurrence of process.executable\\\", ticks: false}\\n }\\n }\\n config: {\\n style: {\\n point: {filled: true}\\n }\\n }\\n}\"},\"aggs\":[]}",
"uiStateJSON": "{}",
"description": "",
"savedSearchId": "ml_auditbeat_docker_process_events_ecs",
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_occurrence_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_occurrence_ecs.json
index b172ac7ff21cc..0d28081818ac7 100644
--- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_occurrence_ecs.json
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/kibana/visualization/ml_auditbeat_hosts_process_occurrence_ecs.json
@@ -1,6 +1,6 @@
{
"title": "ML Auditbeat Hosts: Process Occurrence - experimental (ECS)",
- "visState": "{\"type\":\"vega\",\"params\":{\"spec\":\"{\\n $schema: https://vega.github.io/schema/vega-lite/v2.json\\n mark: {type: \\\"point\\\"}\\n data: {\\n url: {\\n index: \\\"INDEX_PATTERN_NAME\\\"\\n body: {\\n size: 10000\\n query: {\\n bool: {\\n must: [\\n %dashboard_context-must_clause%\\n {\\n exists: {field: \\\"process.executable\\\"}\\n }\\n {\\n function_score: {\\n random_score: {seed: 10, field: \\\"_seq_no\\\"}\\n }\\n }\\n {\\n range: {\\n @timestamp: {\\n %timefilter%: true\\n }\\n }\\n }\\n ]\\n must_not: [\\n \\\"%dashboard_context-must_not_clause%\\\"\\n ]\\n }\\n }\\n script_fields: {\\n process_exe: {\\n script: {source: \\\"params['_source']['process']['executable']\\\"}\\n }\\n }\\n _source: [\\\"@timestamp\\\", \\\"process_exe\\\"]\\n }\\n }\\n format: {property: \\\"hits.hits\\\"}\\n }\\n transform: [\\n {calculate: \\\"toDate(datum._source['@timestamp'])\\\", as: \\\"time\\\"}\\n ]\\n encoding: {\\n x: {\\n field: time\\n type: temporal\\n axis: {labels: true, ticks: true, title: false},\\n timeUnit: utcyearmonthdatehoursminutes\\n }\\n y: {\\n field: fields.process_exe\\n type: ordinal\\n sort: {op: \\\"count\\\", order: \\\"descending\\\"}\\n axis: {labels: true, title: \\\"occurrence of process.executable\\\", ticks: false}\\n }\\n }\\n config: {\\n style: {\\n point: {filled: true}\\n }\\n }\\n}\"},\"aggs\":[]}",
+ "visState": "{\"type\":\"vega\",\"params\":{\"spec\":\"{\\n $schema: https://vega.github.io/schema/vega-lite/v4.json\\n width: \\\"container\\\"\\n mark: {type: \\\"point\\\"}\\n data: {\\n url: {\\n index: \\\"INDEX_PATTERN_NAME\\\"\\n body: {\\n size: 10000\\n query: {\\n bool: {\\n must: [\\n %dashboard_context-must_clause%\\n {\\n exists: {field: \\\"process.executable\\\"}\\n }\\n {\\n function_score: {\\n random_score: {seed: 10, field: \\\"_seq_no\\\"}\\n }\\n }\\n {\\n range: {\\n @timestamp: {\\n %timefilter%: true\\n }\\n }\\n }\\n ]\\n must_not: [\\n \\\"%dashboard_context-must_not_clause%\\\"\\n ]\\n }\\n }\\n script_fields: {\\n process_exe: {\\n script: {source: \\\"params['_source']['process']['executable']\\\"}\\n }\\n }\\n _source: [\\\"@timestamp\\\", \\\"process_exe\\\"]\\n }\\n }\\n format: {property: \\\"hits.hits\\\"}\\n }\\n transform: [\\n {calculate: \\\"toDate(datum._source['@timestamp'])\\\", as: \\\"time\\\"}\\n ]\\n encoding: {\\n x: {\\n field: time\\n type: temporal\\n axis: {labels: true, ticks: true, title: false},\\n timeUnit: utcyearmonthdatehoursminutes\\n }\\n y: {\\n field: fields.process_exe\\n type: ordinal\\n sort: {op: \\\"count\\\", order: \\\"descending\\\"}\\n axis: {labels: true, title: \\\"occurrence of process.executable\\\", ticks: false}\\n }\\n }\\n config: {\\n style: {\\n point: {filled: true}\\n }\\n }\\n}\"},\"aggs\":[]}",
"uiStateJSON": "{}",
"description": "",
"savedSearchId": "ml_auditbeat_hosts_process_events_ecs",
diff --git a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts
index 4090a59c461da..f016898075918 100644
--- a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts
+++ b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts
@@ -27,7 +27,7 @@ interface Results {
}
export function datafeedsProvider(callAsCurrentUser: APICaller) {
- async function forceStartDatafeeds(datafeedIds: string[], start: number, end: number) {
+ async function forceStartDatafeeds(datafeedIds: string[], start?: number, end?: number) {
const jobIds = await getJobIdsByDatafeedId();
const doStartsCalled = datafeedIds.reduce((acc, cur) => {
acc[cur] = false;
@@ -96,7 +96,7 @@ export function datafeedsProvider(callAsCurrentUser: APICaller) {
return opened;
}
- async function startDatafeed(datafeedId: string, start: number, end: number) {
+ async function startDatafeed(datafeedId: string, start?: number, end?: number) {
return callAsCurrentUser('ml.startDatafeed', { datafeedId, start, end });
}
diff --git a/x-pack/plugins/ml/server/models/job_service/index.ts b/x-pack/plugins/ml/server/models/job_service/index.ts
index eb70a3ccecfc1..b6e87b98735ee 100644
--- a/x-pack/plugins/ml/server/models/job_service/index.ts
+++ b/x-pack/plugins/ml/server/models/job_service/index.ts
@@ -10,6 +10,7 @@ import { jobsProvider } from './jobs';
import { groupsProvider } from './groups';
import { newJobCapsProvider } from './new_job_caps';
import { newJobChartsProvider, topCategoriesProvider } from './new_job';
+import { modelSnapshotProvider } from './model_snapshots';
export function jobServiceProvider(callAsCurrentUser: APICaller) {
return {
@@ -19,5 +20,6 @@ export function jobServiceProvider(callAsCurrentUser: APICaller) {
...newJobCapsProvider(callAsCurrentUser),
...newJobChartsProvider(callAsCurrentUser),
...topCategoriesProvider(callAsCurrentUser),
+ ...modelSnapshotProvider(callAsCurrentUser),
};
}
diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts
index 5503169f2d371..852264b3d0337 100644
--- a/x-pack/plugins/ml/server/models/job_service/jobs.ts
+++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts
@@ -6,6 +6,7 @@
import { i18n } from '@kbn/i18n';
import { uniq } from 'lodash';
+import Boom from 'boom';
import { APICaller } from 'kibana/server';
import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states';
import {
@@ -128,6 +129,23 @@ export function jobsProvider(callAsCurrentUser: APICaller) {
return results;
}
+ async function forceStopAndCloseJob(jobId: string) {
+ const datafeedIds = await getDatafeedIdsByJobId();
+ const datafeedId = datafeedIds[jobId];
+ if (datafeedId === undefined) {
+ throw Boom.notFound(`Cannot find datafeed for job ${jobId}`);
+ }
+
+ const dfResult = await callAsCurrentUser('ml.stopDatafeed', { datafeedId, force: true });
+ if (!dfResult || dfResult.stopped !== true) {
+ return { success: false };
+ }
+
+ await callAsCurrentUser('ml.closeJob', { jobId, force: true });
+
+ return { success: true };
+ }
+
async function jobsSummary(jobIds: string[] = []) {
const fullJobsList: CombinedJobWithStats[] = await createFullJobsList();
const fullJobsIds = fullJobsList.map((job) => job.job_id);
@@ -472,6 +490,7 @@ export function jobsProvider(callAsCurrentUser: APICaller) {
forceDeleteJob,
deleteJobs,
closeJobs,
+ forceStopAndCloseJob,
jobsSummary,
jobsWithTimerange,
createFullJobsList,
diff --git a/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts b/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts
new file mode 100644
index 0000000000000..4ffae17fc1c06
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts
@@ -0,0 +1,102 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import Boom from 'boom';
+import { i18n } from '@kbn/i18n';
+import { APICaller } from 'kibana/server';
+import { ModelSnapshot } from '../../../common/types/anomaly_detection_jobs';
+import { datafeedsProvider, MlDatafeedsResponse } from './datafeeds';
+import { MlJobsResponse } from './jobs';
+import { FormCalendar, CalendarManager } from '../calendar';
+
+export interface ModelSnapshotsResponse {
+ count: number;
+ model_snapshots: ModelSnapshot[];
+}
+export interface RevertModelSnapshotResponse {
+ model: ModelSnapshot;
+}
+
+export function modelSnapshotProvider(callAsCurrentUser: APICaller) {
+ const { forceStartDatafeeds, getDatafeedIdsByJobId } = datafeedsProvider(callAsCurrentUser);
+
+ async function revertModelSnapshot(
+ jobId: string,
+ snapshotId: string,
+ replay: boolean,
+ end?: number,
+ deleteInterveningResults: boolean = true,
+ calendarEvents?: [{ start: number; end: number; description: string }]
+ ) {
+ let datafeedId = `datafeed-${jobId}`;
+ // ensure job exists
+ await callAsCurrentUser('ml.jobs', { jobId: [jobId] });
+
+ try {
+ // ensure the datafeed exists
+ // the datafeed is probably called datafeed-
+ await callAsCurrentUser('ml.datafeeds', {
+ datafeedId: [datafeedId],
+ });
+ } catch (e) {
+ // if the datafeed isn't called datafeed-
+ // check all datafeeds to see if one exists that is matched to this job id
+ const datafeedIds = await getDatafeedIdsByJobId();
+ datafeedId = datafeedIds[jobId];
+ if (datafeedId === undefined) {
+ throw Boom.notFound(`Cannot find datafeed for job ${jobId}`);
+ }
+ }
+
+ // ensure the snapshot exists
+ const snapshot = await callAsCurrentUser