[Index Management] Add support for enrich policies (#164080)

This commit is contained in:
Ignacio Rivas 2023-09-20 17:03:42 +02:00 committed by GitHub
parent e02c8740ec
commit d1608f070d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 4663 additions and 27 deletions

View file

@ -338,6 +338,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
asyncSearch: `${ELASTICSEARCH_DOCS}async-search-intro.html`,
dataStreams: `${ELASTICSEARCH_DOCS}data-streams.html`,
deprecationLogging: `${ELASTICSEARCH_DOCS}logging.html#deprecation-logging`,
createEnrichPolicy: `${ELASTICSEARCH_DOCS}put-enrich-policy-api.html`,
matchAllQuery: `${ELASTICSEARCH_DOCS}query-dsl-match-all-query.html`,
enrichPolicies: `${ELASTICSEARCH_DOCS}ingest-enriching-data.html#enrich-policy`,
createIndex: `${ELASTICSEARCH_DOCS}indices-create-index.html`,
frozenIndices: `${ELASTICSEARCH_DOCS}frozen-indices.html`,
gettingStarted: `${ELASTICSEARCH_DOCS}getting-started.html`,

View file

@ -31,6 +31,7 @@ function JsonEditorComp<T extends object = { [key: string]: any }>({
defaultValue,
codeEditorProps,
error: propsError,
...rest
}: Props<T>) {
const {
content,
@ -82,6 +83,7 @@ function JsonEditorComp<T extends object = { [key: string]: any }>({
isInvalid={typeof error === 'string'}
error={error}
fullWidth
{...rest}
>
<CodeEditor
languageId="json"

View file

@ -0,0 +1,118 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act } from 'react-dom/test-utils';
import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
import { HttpSetup } from '@kbn/core/public';
import { EnrichPolicyCreate } from '../../../public/application/sections/enrich_policy_create';
import { indexManagementStore } from '../../../public/application/store';
import { WithAppDependencies, services, TestSubjects } from '../helpers';
const testBedConfig: AsyncTestBedConfig = {
store: () => indexManagementStore(services as any),
memoryRouter: {
initialEntries: [`/enrich_policies/create`],
componentRoutePath: `/:section(enrich_policies)/create`,
},
doMountAsync: true,
};
export interface CreateEnrichPoliciesTestBed extends TestBed<TestSubjects> {
actions: {
clickNextButton: () => Promise<void>;
clickBackButton: () => Promise<void>;
clickRequestTab: () => Promise<void>;
clickCreatePolicy: () => Promise<void>;
completeConfigurationStep: ({ indices }: { indices?: string }) => Promise<void>;
completeFieldsSelectionStep: () => Promise<void>;
isOnConfigurationStep: () => boolean;
isOnFieldSelectionStep: () => boolean;
isOnCreateStep: () => boolean;
};
}
export const setup = async (
httpSetup: HttpSetup,
overridingDependencies: any = {}
): Promise<CreateEnrichPoliciesTestBed> => {
const initTestBed = registerTestBed(
WithAppDependencies(EnrichPolicyCreate, httpSetup, overridingDependencies),
testBedConfig
);
const testBed = await initTestBed();
/**
* User Actions
*/
const isOnConfigurationStep = () => testBed.exists('configurationForm');
const isOnFieldSelectionStep = () => testBed.exists('fieldSelectionForm');
const isOnCreateStep = () => testBed.exists('creationStep');
const clickNextButton = async () => {
await act(async () => {
testBed.find('nextButton').simulate('click');
});
testBed.component.update();
};
const clickBackButton = async () => {
await act(async () => {
testBed.find('backButton').simulate('click');
});
testBed.component.update();
};
const clickCreatePolicy = async (executeAfter?: boolean) => {
await act(async () => {
testBed.find(executeAfter ? 'createAndExecuteButton' : 'createButton').simulate('click');
});
testBed.component.update();
};
const clickRequestTab = async () => {
await act(async () => {
testBed.find('requestTab').simulate('click');
});
testBed.component.update();
};
const completeConfigurationStep = async ({ indices }: { indices?: string }) => {
const { form } = testBed;
form.setInputValue('policyNameField.input', 'test_policy');
form.setSelectValue('policyTypeField', 'match');
form.setSelectValue('policySourceIndicesField', indices ?? 'test-1');
await clickNextButton();
};
const completeFieldsSelectionStep = async () => {
const { form } = testBed;
form.setSelectValue('matchField', 'name');
form.setSelectValue('enrichFields', 'email');
await clickNextButton();
};
return {
...testBed,
actions: {
clickNextButton,
clickBackButton,
clickRequestTab,
clickCreatePolicy,
completeConfigurationStep,
completeFieldsSelectionStep,
isOnConfigurationStep,
isOnFieldSelectionStep,
isOnCreateStep,
},
};
};

View file

@ -0,0 +1,213 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { setupEnvironment } from '../helpers';
import { getMatchingIndices, getFieldsFromIndices } from '../helpers/fixtures';
import { CreateEnrichPoliciesTestBed, setup } from './create_enrich_policy.helpers';
import { getESPolicyCreationApiCall } from '../../../common/lib';
jest.mock('@kbn/kibana-react-plugin/public', () => {
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
return {
...original,
// Mocking CodeEditor, which uses React Monaco under the hood
CodeEditor: (props: any) => (
<input
data-test-subj={props['data-test-subj'] || 'mockCodeEditor'}
data-currentvalue={props.value}
onChange={(e: any) => {
props.onChange(e.jsonContent);
}}
/>
),
};
});
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
...original,
// Mock EuiComboBox as a simple input instead so that its easier to test
EuiComboBox: (props: any) => (
<input
data-test-subj={props['data-test-subj'] || 'mockEuiCombobox'}
data-currentvalue={props.value}
onChange={(e: any) => {
props.onChange(e.target.value.split(', '));
}}
/>
),
};
});
describe('Create enrich policy', () => {
const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: CreateEnrichPoliciesTestBed;
beforeEach(async () => {
httpRequestsMockHelpers.setGetMatchingIndices(getMatchingIndices());
await act(async () => {
testBed = await setup(httpSetup);
});
testBed.component.update();
});
test('Has header and docs link', async () => {
const { exists, component } = testBed;
component.update();
expect(exists('createEnrichPolicyHeaderContent')).toBe(true);
expect(exists('createEnrichPolicyDocumentationLink')).toBe(true);
});
describe('Configuration step', () => {
it('Fields have helpers', async () => {
const { exists } = testBed;
expect(exists('typePopoverIcon')).toBe(true);
expect(exists('uploadFileLink')).toBe(true);
expect(exists('matchAllQueryLink')).toBe(true);
});
it('shows validation errors if form isnt filled', async () => {
await testBed.actions.clickNextButton();
expect(testBed.form.getErrorsMessages()).toHaveLength(3);
});
it('Allows to submit the form when fields are filled', async () => {
const { actions } = testBed;
await testBed.actions.completeConfigurationStep({});
expect(actions.isOnFieldSelectionStep()).toBe(true);
});
});
describe('Fields selection step', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setGetFieldsFromIndices(getFieldsFromIndices());
await act(async () => {
testBed = await setup(httpSetup);
});
testBed.component.update();
await testBed.actions.completeConfigurationStep({});
});
it('shows validation errors if form isnt filled', async () => {
await testBed.actions.clickNextButton();
expect(testBed.form.getErrorsMessages()).toHaveLength(2);
});
it('Allows to submit the form when fields are filled', async () => {
const { form, actions } = testBed;
form.setSelectValue('matchField', 'name');
form.setSelectValue('enrichFields', 'email');
await testBed.actions.clickNextButton();
expect(actions.isOnCreateStep()).toBe(true);
});
it('When no common fields are returned it shows an error callout', async () => {
httpRequestsMockHelpers.setGetFieldsFromIndices({
commonFields: [],
indices: [],
});
await act(async () => {
testBed = await setup(httpSetup);
});
testBed.component.update();
await testBed.actions.completeConfigurationStep({ indices: 'test-1, test-2' });
expect(testBed.exists('noCommonFieldsError')).toBe(true);
});
});
describe('Creation step', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setGetFieldsFromIndices(getFieldsFromIndices());
await act(async () => {
testBed = await setup(httpSetup);
});
testBed.component.update();
await testBed.actions.completeConfigurationStep({});
await testBed.actions.completeFieldsSelectionStep();
});
it('Shows CTAs for creating the policy', async () => {
const { exists } = testBed;
expect(exists('createButton')).toBe(true);
expect(exists('createAndExecuteButton')).toBe(true);
});
it('Shows policy summary and request', async () => {
const { find } = testBed;
expect(find('enrichPolicySummaryList').text()).toContain('test_policy');
await testBed.actions.clickRequestTab();
expect(find('requestBody').text()).toContain(getESPolicyCreationApiCall('test_policy'));
});
it('Shows error message when creating the policy fails', async () => {
const { exists, actions } = testBed;
const error = {
statusCode: 400,
error: 'Bad Request',
message: 'something went wrong...',
};
httpRequestsMockHelpers.setCreateEnrichPolicy(undefined, error);
await actions.clickCreatePolicy();
expect(exists('errorWhenCreatingCallout')).toBe(true);
});
});
it('Can navigate back and forth with next/back buttons', async () => {
httpRequestsMockHelpers.setGetFieldsFromIndices(getFieldsFromIndices());
await act(async () => {
testBed = await setup(httpSetup);
});
const { component, actions } = testBed;
component.update();
// Navigate to create step
await actions.completeConfigurationStep({});
await actions.completeFieldsSelectionStep();
// Clicking back button should take us to fields selection step
await actions.clickBackButton();
expect(actions.isOnFieldSelectionStep()).toBe(true);
// Clicking back button should take us to configuration step
await actions.clickBackButton();
expect(actions.isOnConfigurationStep()).toBe(true);
});
});

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { EnrichPolicyType } from '@elastic/elasticsearch/lib/api/types';
export const indexSettings = {
settings: { index: { number_of_shards: '1' } },
defaults: { index: { flush_after_merge: '512mb' } },
@ -45,3 +47,31 @@ export const indexStats = {
},
},
};
export const createTestEnrichPolicy = (name: string, type: EnrichPolicyType) => ({
name,
type,
sourceIndices: ['users'],
matchField: 'email',
enrichFields: ['first_name', 'last_name', 'city'],
query: {
match_all: {},
},
});
export const getMatchingIndices = () => ({
indices: ['test-1', 'test-2', 'test-3', 'test-4', 'test-5'],
});
export const getFieldsFromIndices = () => ({
commonFields: [],
indices: [
{
index: 'test-1',
fields: [
{ name: 'first_name', type: 'keyword', normalizedType: 'keyword' },
{ name: 'age', type: 'long', normalizedType: 'number' },
],
},
],
});

View file

@ -19,7 +19,8 @@ export interface ResponseError {
// Register helpers to mock HTTP Requests
const registerHttpRequestMockHelpers = (
httpSetup: ReturnType<typeof httpServiceMock.createStartContract>
httpSetup: ReturnType<typeof httpServiceMock.createStartContract>,
shouldDelayResponse: () => boolean
) => {
const mockResponses = new Map<HttpMethod, Map<string, Promise<unknown>>>(
['GET', 'PUT', 'DELETE', 'POST'].map(
@ -28,7 +29,14 @@ const registerHttpRequestMockHelpers = (
);
const mockMethodImplementation = (method: HttpMethod, path: string) => {
return mockResponses.get(method)?.get(path) ?? Promise.resolve({});
const responsePromise = mockResponses.get(method)?.get(path) ?? Promise.resolve({});
if (shouldDelayResponse()) {
return new Promise((resolve) => {
setTimeout(() => resolve(responsePromise), 1000);
});
}
return responsePromise;
};
httpSetup.get.mockImplementation((path) =>
@ -130,6 +138,47 @@ const registerHttpRequestMockHelpers = (
const setLoadTelemetryResponse = (response?: HttpResponse, error?: ResponseError) =>
mockResponse('GET', '/api/ui_counters/_report', response, error);
const setLoadEnrichPoliciesResponse = (response?: HttpResponse, error?: ResponseError) =>
mockResponse('GET', `${INTERNAL_API_BASE_PATH}/enrich_policies`, response, error);
const setGetMatchingIndices = (response?: HttpResponse, error?: ResponseError) =>
mockResponse(
'POST',
`${INTERNAL_API_BASE_PATH}/enrich_policies/get_matching_indices`,
response,
error
);
const setGetFieldsFromIndices = (response?: HttpResponse, error?: ResponseError) =>
mockResponse(
'POST',
`${INTERNAL_API_BASE_PATH}/enrich_policies/get_fields_from_indices`,
response,
error
);
const setCreateEnrichPolicy = (response?: HttpResponse, error?: ResponseError) =>
mockResponse('POST', `${INTERNAL_API_BASE_PATH}/enrich_policies`, response, error);
const setDeleteEnrichPolicyResponse = (
policyName: string,
response?: HttpResponse,
error?: ResponseError
) =>
mockResponse(
'DELETE',
`${INTERNAL_API_BASE_PATH}/enrich_policies/${policyName}`,
response,
error
);
const setExecuteEnrichPolicyResponse = (
policyName: string,
response?: HttpResponse,
error?: ResponseError
) =>
mockResponse('PUT', `${INTERNAL_API_BASE_PATH}/enrich_policies/${policyName}`, response, error);
const setLoadIndexDetailsResponse = (
indexName: string,
response?: HttpResponse,
@ -157,17 +206,30 @@ const registerHttpRequestMockHelpers = (
setLoadComponentTemplatesResponse,
setLoadNodesPluginsResponse,
setLoadTelemetryResponse,
setLoadEnrichPoliciesResponse,
setDeleteEnrichPolicyResponse,
setExecuteEnrichPolicyResponse,
setLoadIndexDetailsResponse,
setCreateIndexResponse,
setGetMatchingIndices,
setGetFieldsFromIndices,
setCreateEnrichPolicy,
};
};
export const init = () => {
let isResponseDelayed = false;
const getDelayResponse = () => isResponseDelayed;
const setDelayResponse = (shouldDelayResponse: boolean) => {
isResponseDelayed = shouldDelayResponse;
};
const httpSetup = httpServiceMock.createSetupContract();
const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup);
const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup, getDelayResponse);
return {
httpSetup,
httpRequestsMockHelpers,
setDelayResponse,
};
};

View file

@ -7,6 +7,7 @@
import React from 'react';
import { merge } from 'lodash';
import { LocationDescriptorObject } from 'history';
import SemVer from 'semver/classes/semver';
import { HttpSetup } from '@kbn/core/public';
@ -15,11 +16,13 @@ import {
docLinksServiceMock,
uiSettingsServiceMock,
themeServiceMock,
scopedHistoryMock,
executionContextServiceMock,
applicationServiceMock,
fatalErrorsServiceMock,
httpServiceMock,
} from '@kbn/core/public/mocks';
import { GlobalFlyout } from '@kbn/es-ui-shared-plugin/public';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
@ -47,14 +50,21 @@ const { GlobalFlyoutProvider } = GlobalFlyout;
export const services = {
extensionsService: new ExtensionsService(),
uiMetricService: new UiMetricService('index_management'),
notificationService: notificationServiceMock.createSetupContract(),
};
services.uiMetricService.setup({ reportUiCounter() {} } as any);
setExtensionsService(services.extensionsService);
setUiMetricService(services.uiMetricService);
const history = scopedHistoryMock.create();
history.createHref.mockImplementation((location: LocationDescriptorObject) => {
return `${location.pathname}?${location.search}`;
});
const appDependencies = {
services,
history,
core: {
getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp,
executionContext: executionContextServiceMock.createStartContract(),

View file

@ -64,8 +64,32 @@ export type TestSubjects =
| 'updateEditIndexSettingsButton'
| 'updateIndexSettingsErrorCallout'
| 'viewButton'
| 'detailPanelTabSelected'
| 'enrichPoliciesTable'
| 'deletePolicyModal'
| 'executePolicyModal'
| 'policyDetailsFlyout'
| 'policyTypeValue'
| 'policyIndicesValue'
| 'policyMatchFieldValue'
| 'policyEnrichFieldsValue'
| 'queryEditor'
| 'createIndexButton'
| 'createIndexNameFieldText'
| 'createIndexCancelButton'
| 'createEnrichPolicyHeaderContent'
| 'createEnrichPolicyDocumentationLink'
| 'policyNameField.input'
| 'policyTypeField'
| 'policySourceIndicesField'
| 'typePopoverIcon'
| 'uploadFileLink'
| 'matchAllQueryLink'
| 'matchField'
| 'enrichFields'
| 'noCommonFieldsError'
| 'createButton'
| 'createAndExecuteButton'
| 'enrichPolicySummaryList'
| 'requestBody'
| 'errorWhenCreatingCallout'
| 'createIndexSaveButton';

View file

@ -0,0 +1,132 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act } from 'react-dom/test-utils';
import {
registerTestBed,
TestBed,
AsyncTestBedConfig,
findTestSubject,
} from '@kbn/test-jest-helpers';
import { HttpSetup } from '@kbn/core/public';
import { IndexManagementHome } from '../../../public/application/sections/home';
import { indexManagementStore } from '../../../public/application/store';
import { WithAppDependencies, services, TestSubjects } from '../helpers';
const testBedConfig: AsyncTestBedConfig = {
store: () => indexManagementStore(services as any),
memoryRouter: {
initialEntries: [`/enrich_policies`],
componentRoutePath: `/:section(enrich_policies)`,
},
doMountAsync: true,
};
export interface EnrichPoliciesTestBed extends TestBed<TestSubjects> {
actions: {
goToEnrichPoliciesTab: () => void;
clickReloadPoliciesButton: () => void;
clickDeletePolicyAt: (index: number) => Promise<void>;
clickConfirmDeletePolicyButton: () => Promise<void>;
clickExecutePolicyAt: (index: number) => Promise<void>;
clickConfirmExecutePolicyButton: () => Promise<void>;
clickEnrichPolicyAt: (index: number) => Promise<void>;
};
}
export const setup = async (
httpSetup: HttpSetup,
overridingDependencies: any = {}
): Promise<EnrichPoliciesTestBed> => {
const initTestBed = registerTestBed(
WithAppDependencies(IndexManagementHome, httpSetup, overridingDependencies),
testBedConfig
);
const testBed = await initTestBed();
/**
* User Actions
*/
const goToEnrichPoliciesTab = () => testBed.find('enrich_policiesTab').simulate('click');
const clickReloadPoliciesButton = () => testBed.find('reloadPoliciesButton').simulate('click');
const clickDeletePolicyAt = async (index: number) => {
const { rows } = testBed.table.getMetaData('enrichPoliciesTable');
const deletePolicyButton = findTestSubject(rows[index].reactWrapper, 'deletePolicyButton');
await act(async () => {
deletePolicyButton.simulate('click');
});
testBed.component.update();
};
const clickConfirmDeletePolicyButton = async () => {
const modal = testBed.find('deletePolicyModal');
const confirmButton = findTestSubject(modal, 'confirmModalConfirmButton');
await act(async () => {
confirmButton.simulate('click');
});
testBed.component.update();
};
const clickExecutePolicyAt = async (index: number) => {
const { rows } = testBed.table.getMetaData('enrichPoliciesTable');
const executePolicyButton = findTestSubject(rows[index].reactWrapper, 'executePolicyButton');
await act(async () => {
executePolicyButton.simulate('click');
});
testBed.component.update();
};
const clickConfirmExecutePolicyButton = async () => {
const modal = testBed.find('executePolicyModal');
const confirmButton = findTestSubject(modal, 'confirmModalConfirmButton');
await act(async () => {
confirmButton.simulate('click');
});
testBed.component.update();
};
const clickEnrichPolicyAt = async (index: number) => {
const { component, table, router } = testBed;
const { rows } = table.getMetaData('enrichPoliciesTable');
const policyLink = findTestSubject(rows[index].reactWrapper, 'enrichPolicyDetailsLink');
await act(async () => {
const { href } = policyLink.props();
router.navigateTo(href!);
});
component.update();
};
return {
...testBed,
actions: {
goToEnrichPoliciesTab,
clickReloadPoliciesButton,
clickDeletePolicyAt,
clickConfirmDeletePolicyButton,
clickExecutePolicyAt,
clickConfirmExecutePolicyButton,
clickEnrichPolicyAt,
},
};
};

View file

@ -0,0 +1,263 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { notificationServiceMock } from '@kbn/core/public/mocks';
import { setupEnvironment } from '../helpers';
import { createTestEnrichPolicy } from '../helpers/fixtures';
import { EnrichPoliciesTestBed, setup } from './enrich_policies.helpers';
import { notificationService } from '../../../public/application/services/notification';
jest.mock('@kbn/kibana-react-plugin/public', () => {
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
return {
...original,
// Mocking CodeEditor, which uses React Monaco under the hood
CodeEditor: (props: any) => (
<input
data-test-subj={props['data-test-subj'] || 'mockCodeEditor'}
data-currentvalue={props.value}
onChange={(e: any) => {
props.onChange(e.jsonContent);
}}
/>
),
};
});
describe('Enrich policies tab', () => {
const { httpSetup, httpRequestsMockHelpers, setDelayResponse } = setupEnvironment();
let testBed: EnrichPoliciesTestBed;
describe('empty states', () => {
beforeEach(async () => {
setDelayResponse(false);
});
test('displays a loading prompt', async () => {
setDelayResponse(true);
testBed = await setup(httpSetup);
await act(async () => {
testBed.actions.goToEnrichPoliciesTab();
});
const { exists, component } = testBed;
component.update();
expect(exists('sectionLoading')).toBe(true);
});
test('displays a error prompt', async () => {
const error = {
statusCode: 400,
error: 'Bad Request',
message: 'something went wrong...',
};
httpRequestsMockHelpers.setLoadEnrichPoliciesResponse(undefined, error);
testBed = await setup(httpSetup);
await act(async () => {
testBed.actions.goToEnrichPoliciesTab();
});
const { exists, component } = testBed;
component.update();
expect(exists('sectionError')).toBe(true);
});
});
describe('policies list', () => {
let testPolicy: ReturnType<typeof createTestEnrichPolicy>;
beforeEach(async () => {
testPolicy = createTestEnrichPolicy('policy-match', 'match');
httpRequestsMockHelpers.setLoadEnrichPoliciesResponse([
testPolicy,
createTestEnrichPolicy('policy-range', 'range'),
]);
testBed = await setup(httpSetup);
await act(async () => {
testBed.actions.goToEnrichPoliciesTab();
});
testBed.component.update();
});
it('shows enrich policies in table', async () => {
const { table } = testBed;
expect(table.getMetaData('enrichPoliciesTable').rows.length).toBe(2);
});
it('can reload the table data through a call to action', async () => {
const { actions } = testBed;
// Reset mock to clear calls from setup
httpSetup.get.mockClear();
await act(async () => {
actions.clickReloadPoliciesButton();
});
// Should have made a call to load the policies after the reload
// button is clicked.
expect(httpSetup.get.mock.calls).toHaveLength(1);
});
describe('details flyout', () => {
it('can open the details flyout', async () => {
const { actions, exists } = testBed;
await actions.clickEnrichPolicyAt(0);
expect(exists('policyDetailsFlyout')).toBe(true);
});
it('contains all the necessary policy fields', async () => {
const { actions, find } = testBed;
await actions.clickEnrichPolicyAt(0);
expect(find('policyTypeValue').text()).toBe(testPolicy.type);
expect(find('policyIndicesValue').text()).toBe(testPolicy.sourceIndices.join(', '));
expect(find('policyMatchFieldValue').text()).toBe(testPolicy.matchField);
expect(find('policyEnrichFieldsValue').text()).toBe(testPolicy.enrichFields.join(', '));
const codeEditorValue = find('queryEditor')
.at(0)
.getDOMNode()
.getAttribute('data-currentvalue');
expect(JSON.parse(codeEditorValue || '')).toEqual(testPolicy.query);
});
});
describe('policy actions', () => {
const notificationsServiceMock = notificationServiceMock.createStartContract();
beforeEach(async () => {
notificationService.setup(notificationsServiceMock);
httpRequestsMockHelpers.setLoadEnrichPoliciesResponse([
createTestEnrichPolicy('policy-match', 'match'),
]);
testBed = await setup(httpSetup, {
services: {
notificationService,
},
});
await act(async () => {
testBed.actions.goToEnrichPoliciesTab();
});
testBed.component.update();
});
describe('deletion', () => {
it('can delete a policy', async () => {
const { actions, exists } = testBed;
httpRequestsMockHelpers.setDeleteEnrichPolicyResponse('policy-match', {
acknowledged: true,
});
await actions.clickDeletePolicyAt(0);
expect(exists('deletePolicyModal')).toBe(true);
await actions.clickConfirmDeletePolicyButton();
expect(notificationsServiceMock.toasts.add).toHaveBeenLastCalledWith(
expect.objectContaining({
title: 'Deleted policy-match',
})
);
expect(httpSetup.delete.mock.calls.length).toBe(1);
});
test('displays an error toast if it fails', async () => {
const { actions, exists } = testBed;
const error = {
statusCode: 400,
error: 'Bad Request',
message: 'something went wrong...',
};
httpRequestsMockHelpers.setDeleteEnrichPolicyResponse('policy-match', undefined, error);
await actions.clickDeletePolicyAt(0);
expect(exists('deletePolicyModal')).toBe(true);
await actions.clickConfirmDeletePolicyButton();
expect(notificationsServiceMock.toasts.add).toHaveBeenLastCalledWith(
expect.objectContaining({
title: `Error deleting enrich policy: 'something went wrong...'`,
})
);
});
});
describe('execution', () => {
it('can execute a policy', async () => {
const { actions, exists } = testBed;
httpRequestsMockHelpers.setExecuteEnrichPolicyResponse('policy-match', {
acknowledged: true,
});
await actions.clickExecutePolicyAt(0);
expect(exists('executePolicyModal')).toBe(true);
await actions.clickConfirmExecutePolicyButton();
expect(notificationsServiceMock.toasts.add).toHaveBeenLastCalledWith(
expect.objectContaining({
title: 'Executed policy-match',
})
);
expect(httpSetup.put.mock.calls.length).toBe(1);
});
test('displays an error toast if it fails', async () => {
const { actions, exists } = testBed;
const error = {
statusCode: 400,
error: 'Bad Request',
message: 'something went wrong...',
};
httpRequestsMockHelpers.setExecuteEnrichPolicyResponse('policy-match', undefined, error);
await actions.clickExecutePolicyAt(0);
expect(exists('executePolicyModal')).toBe(true);
await actions.clickConfirmExecutePolicyButton();
expect(notificationsServiceMock.toasts.add).toHaveBeenLastCalledWith(
expect.objectContaining({
title: `Error executing enrich policy: 'something went wrong...'`,
})
);
});
});
});
});
});

View file

@ -56,9 +56,15 @@ describe('<IndexManagementHome />', () => {
const indexManagementContainer = find('indexManagementHeaderContent');
const tabListContainer = indexManagementContainer.find('div.euiTabs');
const allTabs = tabListContainer.children();
const allTabsLabels = ['Indices', 'Data Streams', 'Index Templates', 'Component Templates'];
const allTabsLabels = [
'Indices',
'Data Streams',
'Index Templates',
'Component Templates',
'Enrich Policies',
];
expect(allTabs.length).toBe(4);
expect(allTabs.length).toBe(5);
for (let i = 0; i < allTabs.length; i++) {
expect(tabListContainer.childAt(i).text()).toEqual(allTabsLabels[i]);
}

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EnrichSummary, EnrichPolicyType } from '@elastic/elasticsearch/lib/api/types';
import type { SerializedEnrichPolicy } from '../types';
export const getPolicyType = (policy: EnrichSummary): EnrichPolicyType => {
if (policy.config.match) {
return 'match';
}
if (policy.config.geo_match) {
return 'geo_match';
}
if (policy.config.range) {
return 'range';
}
throw new Error('Unknown policy type');
};
export const getESPolicyCreationApiCall = (policyName: string) => {
return `PUT _enrich/policy/${policyName}`;
};
export const serializeAsESPolicy = (policy: SerializedEnrichPolicy) => {
const policyType = policy.type as EnrichPolicyType;
return {
[policyType]: {
indices: policy.sourceIndices,
match_field: policy.matchField,
enrich_fields: policy.enrichFields,
query: policy.query,
},
};
};

View file

@ -23,3 +23,5 @@ export {
deserializeComponentTemplateList,
serializeComponentTemplate,
} from './component_template_serialization';
export { getPolicyType, serializeAsESPolicy, getESPolicyCreationApiCall } from './enrich_policies';

View file

@ -13,4 +13,21 @@ export interface SerializedEnrichPolicy {
sourceIndices: string[];
matchField: string;
enrichFields: string[];
query?: Record<string, any>;
}
export interface FieldItem {
name: string;
type: string;
normalizedType: string;
}
export interface IndexWithFields {
index: string;
fields: FieldItem[];
}
export interface FieldFromIndicesRequest {
commonFields: FieldItem[];
indices: IndexWithFields[];
}

View file

@ -17,6 +17,7 @@ import { IndexManagementHome, homeSections, Section } from './sections/home';
import { TemplateCreate } from './sections/template_create';
import { TemplateClone } from './sections/template_clone';
import { TemplateEdit } from './sections/template_edit';
import { EnrichPolicyCreate } from './sections/enrich_policy_create';
import { useAppContext } from './app_context';
import {
ComponentTemplateCreate,
@ -51,6 +52,7 @@ export const AppWithoutRouter = () => (
component={ComponentTemplateClone}
/>
<Route exact path="/edit_component_template/:name*" component={ComponentTemplateEdit} />
<Route exact path="/enrich_policies/create" component={EnrichPolicyCreate} />
<Route path={`/:section(${homeSections.join('|')})`} component={IndexManagementHome} />
<Redirect from={`/`} to={`/${Section.Indices}`} />
</Routes>

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { createContext, useContext, useState } from 'react';
import type { SerializedEnrichPolicy } from '../../../../common';
export type DraftPolicy = Partial<SerializedEnrichPolicy>;
export interface CompletionState {
configurationStep: boolean;
fieldsSelectionStep: boolean;
}
export interface Context {
draft: DraftPolicy;
updateDraft: React.Dispatch<React.SetStateAction<DraftPolicy>>;
completionState: CompletionState;
updateCompletionState: React.Dispatch<React.SetStateAction<CompletionState>>;
}
export const CreatePolicyContext = createContext<Context>({} as any);
export const CreatePolicyContextProvider = ({ children }: { children: React.ReactNode }) => {
const [draft, updateDraft] = useState<DraftPolicy>({});
const [completionState, updateCompletionState] = useState<CompletionState>({
configurationStep: false,
fieldsSelectionStep: false,
});
const contextValue = {
draft,
updateDraft,
completionState,
updateCompletionState,
};
return (
<CreatePolicyContext.Provider value={contextValue}>{children}</CreatePolicyContext.Provider>
);
};
export const useCreatePolicyContext = () => {
const ctx = useContext(CreatePolicyContext);
if (!ctx) throw new Error('Cannot use outside of create policy context');
return ctx;
};

View file

@ -0,0 +1,151 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useMemo, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSteps, EuiStepStatus, EuiCallOut, EuiSpacer } from '@elastic/eui';
import { useAppContext } from '../../app_context';
import { ConfigurationStep, FieldSelectionStep, CreateStep } from './steps';
import { useCreatePolicyContext } from './create_policy_context';
import { createEnrichPolicy } from '../../services/api';
import type { Error } from '../../../shared_imports';
import type { SerializedEnrichPolicy } from '../../../../common';
const CONFIGURATION = 1;
const FIELD_SELECTION = 2;
const CREATE = 3;
export const CreatePolicyWizard = () => {
const {
history,
services: { notificationService },
} = useAppContext();
const { draft, completionState } = useCreatePolicyContext();
const [currentStep, setCurrentStep] = useState(CONFIGURATION);
const [isLoading, setIsLoading] = useState(false);
const [createError, setCreateError] = useState<Error | null>(null);
const getStepStatus = useCallback(
(forStep: number): EuiStepStatus => {
if (currentStep === forStep) {
return 'current';
}
return 'incomplete';
},
[currentStep]
);
const onSubmit = useCallback(
async (executePolicyAfterCreation?: boolean) => {
setIsLoading(true);
const { error, data } = await createEnrichPolicy(
draft as SerializedEnrichPolicy,
executePolicyAfterCreation
);
setIsLoading(false);
if (data) {
const toastMessage = executePolicyAfterCreation
? i18n.translate(
'xpack.idxMgmt.enrichPoliciesCreate.createAndExecuteNotificationMessage',
{
defaultMessage: 'Created and executed policy: {policyName}',
values: { policyName: draft.name },
}
)
: i18n.translate('xpack.idxMgmt.enrichPoliciesCreate.createNotificationMessage', {
defaultMessage: 'Created policy: {policyName}',
values: { policyName: draft.name },
});
notificationService.showSuccessToast(toastMessage);
}
if (error) {
setCreateError(error);
return;
}
history.push('/enrich_policies');
},
[draft, history, setIsLoading, setCreateError, notificationService]
);
const changeCurrentStepTo = useCallback(
(step: number) => {
setCurrentStep(step);
setCreateError(null);
},
[setCurrentStep, setCreateError]
);
const stepDefinitions = useMemo(
() => [
{
step: CONFIGURATION,
title: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.configurationStepLabel', {
defaultMessage: 'Configuration',
}),
status: completionState.configurationStep ? 'complete' : getStepStatus(CONFIGURATION),
children: currentStep === CONFIGURATION && (
<ConfigurationStep onNext={() => changeCurrentStepTo(FIELD_SELECTION)} />
),
},
{
step: FIELD_SELECTION,
title: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStepLabel', {
defaultMessage: 'Field selection',
}),
status: completionState.fieldsSelectionStep ? 'complete' : getStepStatus(FIELD_SELECTION),
children: currentStep === FIELD_SELECTION && (
<FieldSelectionStep
onNext={() => changeCurrentStepTo(CREATE)}
onBack={() => changeCurrentStepTo(CONFIGURATION)}
/>
),
},
{
step: CREATE,
title: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.createStepLabel', {
defaultMessage: 'Create',
}),
status: (currentStep === CREATE ? 'current' : 'incomplete') as EuiStepStatus,
children: currentStep === CREATE && (
<CreateStep
onSubmit={onSubmit}
isLoading={isLoading}
onBack={() => changeCurrentStepTo(FIELD_SELECTION)}
/>
),
},
],
[currentStep, changeCurrentStepTo, completionState, getStepStatus, isLoading, onSubmit]
);
return (
<>
{createError && (
<>
<EuiCallOut
title={i18n.translate('xpack.idxMgmt.enrichPolicyCreate.errorTitle', {
defaultMessage: 'Unable to create your policy',
})}
color="danger"
iconType="error"
data-test-subj="errorWhenCreatingCallout"
>
<p className="eui-textBreakWord">{createError?.message || createError?.error}</p>
</EuiCallOut>
<EuiSpacer size="xl" />
</>
)}
<EuiSteps steps={stepDefinitions} />
</>
);
};

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButtonEmpty, EuiPageHeader, EuiSpacer } from '@elastic/eui';
import { documentationService } from '../../services/documentation';
import { breadcrumbService, IndexManagementBreadcrumb } from '../../services/breadcrumbs';
import { CreatePolicyWizard } from './create_policy_wizard';
import { CreatePolicyContextProvider } from './create_policy_context';
export const EnrichPolicyCreate: React.FunctionComponent<RouteComponentProps> = () => {
useEffect(() => {
breadcrumbService.setBreadcrumbs(IndexManagementBreadcrumb.enrichPoliciesCreate);
}, []);
return (
<CreatePolicyContextProvider>
<EuiPageHeader
data-test-subj="createEnrichPolicyHeaderContent"
pageTitle={
<span data-test-subj="appTitle">
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.appTitle"
defaultMessage="Create enrich policy"
/>
</span>
}
description={
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.appDescription"
defaultMessage="Specify how to retrieve and enrich your incoming data."
/>
}
bottomBorder
rightSideItems={[
<EuiButtonEmpty
href={documentationService.getCreateEnrichPolicyLink()}
target="_blank"
iconType="help"
data-test-subj="createEnrichPolicyDocumentationLink"
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.titleDocsLinkText"
defaultMessage="Documentation"
/>
</EuiButtonEmpty>,
]}
/>
<EuiSpacer size="l" />
<CreatePolicyWizard />
</CreatePolicyContextProvider>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { EnrichPolicyCreate } from './enrich_policy_create';

View file

@ -0,0 +1,352 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { omit, isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiIcon,
EuiCode,
EuiButton,
EuiText,
EuiLink,
EuiSpacer,
} from '@elastic/eui';
import {
useForm,
Form,
fieldValidators,
FormSchema,
FIELD_TYPES,
UseField,
TextField,
SelectField,
JsonEditorField,
} from '../../../../shared_imports';
import { useAppContext } from '../../../app_context';
import { IndicesSelector } from './fields/indices_selector';
import { documentationService } from '../../../services/documentation';
import { useCreatePolicyContext, DraftPolicy } from '../create_policy_context';
interface Props {
onNext: () => void;
}
export const configurationFormSchema: FormSchema = {
name: {
type: FIELD_TYPES.TEXT,
label: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.configurationStep.policyNameField', {
defaultMessage: 'Policy name',
}),
validations: [
{
validator: fieldValidators.emptyField(
i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.configurationStep.policyNameRequiredError',
{
defaultMessage: 'A policy name value is required.',
}
)
),
},
],
},
type: {
type: FIELD_TYPES.SELECT,
label: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.configurationStep.policyTypeLabel', {
defaultMessage: 'Policy type',
}),
validations: [
{
validator: fieldValidators.emptyField(
i18n.translate('xpack.idxMgmt.enrichPolicyCreate.configurationStep.typeRequiredError', {
defaultMessage: 'A policy type value is required.',
})
),
},
],
},
sourceIndices: {
label: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.configurationStep.sourceIndicesLabel', {
defaultMessage: 'Source indices',
}),
validations: [
{
validator: fieldValidators.emptyField(
i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.configurationStep.sourceIndicesRequiredError',
{
defaultMessage: 'At least one source index is required.',
}
)
),
},
],
},
query: {
type: FIELD_TYPES.JSON,
label: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.configurationStep.queryLabel', {
defaultMessage: 'Query (optional)',
}),
serializer: (jsonString: string) => {
let parsedJSON: any;
try {
parsedJSON = JSON.parse(jsonString);
} catch {
parsedJSON = {};
}
return parsedJSON;
},
deserializer: (json: any) =>
json && typeof json === 'object' ? JSON.stringify(json, null, 2) : '{\n\n}',
validations: [
{
validator: fieldValidators.isJsonField(
i18n.translate('xpack.idxMgmt.enrichPolicyCreate.configurationStep.queryInvalidError', {
defaultMessage: 'The query is not valid JSON.',
}),
{ allowEmptyString: true }
),
},
],
},
};
export const ConfigurationStep = ({ onNext }: Props) => {
const {
core: {
application: { getUrlForApp },
},
} = useAppContext();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const { draft, updateDraft, updateCompletionState } = useCreatePolicyContext();
const { form } = useForm({
defaultValue: draft,
schema: configurationFormSchema,
id: 'configurationForm',
});
const onSubmit = async () => {
const { isValid, data } = await form.submit();
if (!isValid) {
return;
}
// Update form completion state
updateCompletionState((prevCompletionState) => ({
...prevCompletionState,
configurationStep: true,
}));
// Update draft state with the data of the form
updateDraft((prevDraft: DraftPolicy) => ({
...prevDraft,
...(isEmpty(data.query) ? omit(data, 'query') : data),
}));
// And then navigate to the next step
onNext();
};
return (
<Form form={form} data-test-subj="configurationForm">
<UseField
path="name"
component={TextField}
componentProps={{ fullWidth: false }}
data-test-subj="policyNameField"
/>
<UseField
path="type"
component={SelectField}
labelAppend={
<EuiPopover
button={
<EuiLink
data-test-subj="typePopoverIcon"
onClick={() => setIsPopoverOpen((isOpen) => !isOpen)}
>
<EuiIcon type="questionInCircle" />
</EuiLink>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
>
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.configurationStep.typeTitlePopOver"
defaultMessage="Determines how to match the data to incoming documents."
/>
</p>
<ul>
<li>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.configurationStep.matchTypePopOver"
defaultMessage="{type} matches an exact value."
values={{ type: <EuiCode transparentBackground>Match</EuiCode> }}
/>
</li>
<li>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.configurationStep.geoMatchTypePopOver"
defaultMessage="{type} matches a geographic location."
values={{ type: <EuiCode transparentBackground>Geo match</EuiCode> }}
/>
</li>
<li>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.configurationStep.rangeTypePopOver"
defaultMessage="{type} matches a number, date, or IP address range."
values={{ type: <EuiCode transparentBackground>Range</EuiCode> }}
/>
</li>
</ul>
</EuiText>
</EuiPopover>
}
componentProps={{
fullWidth: false,
euiFieldProps: {
'data-test-subj': 'policyTypeField',
options: [
{
value: 'match',
text: i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.configurationStep.matchOption',
{ defaultMessage: 'Match' }
),
},
{
value: 'geo_match',
text: i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.configurationStep.geoMatchOption',
{ defaultMessage: 'Geo match' }
),
},
{
value: 'range',
text: i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.configurationStep.rangeOption',
{ defaultMessage: 'Range' }
),
},
],
},
}}
/>
<UseField
path="sourceIndices"
component={IndicesSelector}
labelAppend={
<EuiText size="xs">
<EuiLink
target="_blank"
data-test-subj="uploadFileLink"
href={getUrlForApp('home', { path: '#/tutorial_directory/fileDataViz' })}
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.configurationStep.uploadFileLink"
defaultMessage="Upload a file"
/>
</EuiLink>
</EuiText>
}
componentProps={{
euiFieldProps: {
'data-test-subj': 'policySourceIndicesField',
},
fullWidth: false,
}}
/>
<UseField
path="query"
component={JsonEditorField}
componentProps={{
fullWidth: false,
helpText: (
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.configurationStep.queryHelpText"
defaultMessage="Defaults to: {code} query."
values={{
code: (
<EuiLink
external
target="_blank"
data-test-subj="matchAllQueryLink"
href={documentationService.getMatchAllQueryLink()}
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.configurationStep.matchAllLink"
defaultMessage="match_all"
/>
</EuiLink>
),
}}
/>
),
codeEditorProps: {
height: '300px',
allowFullScreen: true,
'aria-label': i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.configurationStep.ariaLabelQuery',
{
defaultMessage: 'Query field data editor',
}
),
options: {
lineNumbers: 'off',
tabSize: 2,
automaticLayout: true,
},
},
}}
/>
<EuiSpacer />
<EuiFlexGroup
data-test-subj="configureStep"
justifyContent="spaceBetween"
style={{ maxWidth: 400 }}
>
<EuiFlexItem grow={false} />
<EuiFlexItem grow={false}>
<EuiButton
fill
color="primary"
iconSide="right"
iconType="arrowRight"
disabled={form.isValid === false}
data-test-subj="nextButton"
onClick={onSubmit}
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.configurationStep.nextButtonLabel"
defaultMessage="Next"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</Form>
);
};

View file

@ -0,0 +1,266 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiTabbedContent,
EuiSpacer,
EuiCodeBlock,
} from '@elastic/eui';
import type { SerializedEnrichPolicy } from '../../../../../common';
import { useCreatePolicyContext } from '../create_policy_context';
import { serializeAsESPolicy, getESPolicyCreationApiCall } from '../../../../../common/lib';
// Beyond a certain point, highlighting the syntax will bog down performance to unacceptable
// levels. This way we prevent that happening for very large requests.
const getLanguageForQuery = (query: string) => (query.length < 60000 ? 'json' : undefined);
const SummaryTab = ({ policy }: { policy: SerializedEnrichPolicy }) => {
const queryAsString = policy.query ? JSON.stringify(policy.query, null, 2) : '';
const language = getLanguageForQuery(queryAsString);
return (
<>
<EuiSpacer size="m" />
<EuiDescriptionList data-test-subj="enrichPolicySummaryList">
{/* Policy name */}
{policy.name && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicyCreate.createStep.nameLabel', {
defaultMessage: 'Name',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="policyNameValue">
{policy.name}
</EuiDescriptionListDescription>
</>
)}
{/* Policy type */}
{policy.type && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicyCreate.createStep.typeLabel', {
defaultMessage: 'Type',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="policyTypeValue">
{policy.type}
</EuiDescriptionListDescription>
</>
)}
{/* Policy source indices */}
{policy.sourceIndices && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicyCreate.createStep.sourceIndicesLabel', {
defaultMessage: 'Source indices',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="policyIndicesValue">
<EuiText size="s">
<ul>
{policy.sourceIndices.map((index: string) => (
<li key={index} className="eui-textBreakWord">
{index}
</li>
))}
</ul>
</EuiText>
</EuiDescriptionListDescription>
</>
)}
{/* Policy match field */}
{policy.matchField && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicyCreate.createStep.matchFieldLabel', {
defaultMessage: 'Match field',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="policyMatchFieldValue">
{policy.matchField}
</EuiDescriptionListDescription>
</>
)}
{/* Policy enrich fields */}
{policy.enrichFields && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicyCreate.createStep.enrichFieldsLabel', {
defaultMessage: 'Enrich fields',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="policyEnrichFieldsValue">
<EuiText size="s">
<ul>
{policy.enrichFields.map((field: string) => (
<li key={field} className="eui-textBreakWord">
{field}
</li>
))}
</ul>
</EuiText>
</EuiDescriptionListDescription>
</>
)}
{/* Policy query */}
{policy.query && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicyCreate.createStep.queryLabel', {
defaultMessage: 'Query',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<EuiCodeBlock language={language} isCopyable>
{queryAsString}
</EuiCodeBlock>
</EuiDescriptionListDescription>
</>
)}
</EuiDescriptionList>
</>
);
};
const RequestTab = ({ policy }: { policy: SerializedEnrichPolicy }) => {
const request = JSON.stringify(serializeAsESPolicy(policy), null, 2);
const language = getLanguageForQuery(request);
return (
<>
<EuiSpacer size="m" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.createStep.descriptionText"
defaultMessage="This request will create the following enrich policy."
/>
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiCodeBlock language={language} isCopyable data-test-subj="requestBody">
{getESPolicyCreationApiCall(policy.name)}
{`\n`}
{request}
</EuiCodeBlock>
</>
);
};
const CREATE_AND_EXECUTE_POLICY = true;
interface Props {
onSubmit: (executePolicyAfterCreation?: boolean) => void;
onBack: () => void;
isLoading: boolean;
}
export const CreateStep = ({ onBack, onSubmit, isLoading }: Props) => {
const { draft } = useCreatePolicyContext();
const summaryTabs = [
{
id: 'summary',
name: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.createStep.summaryTabLabel', {
defaultMessage: 'Summary',
}),
'data-test-subj': 'summaryTab',
content: <SummaryTab policy={draft as SerializedEnrichPolicy} />,
},
{
id: 'request',
name: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.createStep.requestTabLabel', {
defaultMessage: 'Request',
}),
'data-test-subj': 'requestTab',
content: <RequestTab policy={draft as SerializedEnrichPolicy} />,
},
];
return (
<>
<EuiTabbedContent tabs={summaryTabs} />
<EuiSpacer />
<EuiSpacer size="l" />
<EuiFlexGroup
data-test-subj="creationStep"
justifyContent="spaceBetween"
style={{ maxWidth: 400 }}
>
<EuiFlexItem grow={false}>
<EuiButton
minWidth={false}
color="primary"
iconSide="left"
iconType="arrowLeft"
data-test-subj="backButton"
onClick={onBack}
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.createStep.backButtonLabel"
defaultMessage="Back"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
onClick={() => onSubmit(CREATE_AND_EXECUTE_POLICY)}
isDisabled={isLoading}
data-test-subj="createAndExecuteButton"
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.createStep.createAndExecuteButtonLabel"
defaultMessage="Create and execute"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="primary"
onClick={() => onSubmit()}
isDisabled={isLoading}
data-test-subj="createButton"
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.createStep.createButtonLabel"
defaultMessage="Create"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,288 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiIconTip,
EuiSpacer,
EuiComboBoxOptionOption,
EuiCallOut,
} from '@elastic/eui';
import { FieldIcon as KbnFieldIcon } from '@kbn/react-field';
import {
useForm,
Form,
fieldValidators,
FormSchema,
UseField,
FIELD_TYPES,
ComboBoxField,
} from '../../../../shared_imports';
import type { IndexWithFields, FieldItem } from '../../../../../common';
import { getFieldsFromIndices } from '../../../services/api';
import { useCreatePolicyContext, DraftPolicy } from '../create_policy_context';
interface Props {
onNext: () => void;
onBack: () => void;
}
export const fieldSelectionFormSchema: FormSchema = {
matchField: {
// Since this ComboBoxField is not a multi-select, we need to serialize/deserialize the value
// into a string to be able to save it in the policy.
defaultValue: '',
type: FIELD_TYPES.COMBO_BOX,
serializer: (v: string[]) => {
return v.join(', ');
},
deserializer: (v: string) => {
return v.length === 0 ? [] : v.split(', ');
},
label: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.matchFieldField', {
defaultMessage: 'Match field',
}),
validations: [
{
validator: fieldValidators.emptyField(
i18n.translate('xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.matchFieldRequired', {
defaultMessage: 'A match field is required.',
})
),
},
],
},
enrichFields: {
defaultValue: [],
type: FIELD_TYPES.COMBO_BOX,
label: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.enrichFieldsField', {
defaultMessage: 'Enrich fields',
}),
validations: [
{
validator: fieldValidators.emptyField(
i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.enrichFieldsRequired',
{
defaultMessage: 'At least one enrich field is required.',
}
)
),
},
],
},
};
const buildFieldOption = (field: FieldItem) => ({
label: field.name,
prepend: <KbnFieldIcon type={field.normalizedType} />,
});
export const FieldSelectionStep = ({ onBack, onNext }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [fieldOptions, setFieldOptions] = useState<EuiComboBoxOptionOption[]>([]);
const [matchFieldOptions, setMatchFieldOptions] = useState<EuiComboBoxOptionOption[]>([]);
const { draft, updateDraft, updateCompletionState } = useCreatePolicyContext();
useEffect(() => {
const fetchFields = async () => {
setIsLoading(true);
const { data } = await getFieldsFromIndices(draft.sourceIndices as string[]);
setIsLoading(false);
if (data?.commonFields?.length) {
setMatchFieldOptions(data.commonFields.map(buildFieldOption));
// If there is only one index, we can use the fields of that index as match field options
} else if (data?.indices?.length === 1) {
setMatchFieldOptions(data.indices[0].fields.map(buildFieldOption));
}
if (data?.indices?.length) {
setFieldOptions(
data.indices.map((index: IndexWithFields) => ({
label: index.index,
options: index.fields.map(buildFieldOption),
}))
);
}
};
fetchFields();
}, [draft.sourceIndices]);
const { form } = useForm({
defaultValue: draft,
schema: fieldSelectionFormSchema,
id: 'fieldSelectionForm',
});
const onSubmit = async () => {
const { isValid, data } = await form.submit();
if (!isValid) {
return;
}
// Update form completion state
updateCompletionState((prevCompletionState) => ({
...prevCompletionState,
fieldsSelectionStep: true,
}));
// Update draft state with the data of the form
updateDraft((prevDraft: DraftPolicy) => ({
...prevDraft,
...data,
}));
// And then navigate to the next step
onNext();
};
const hasSelectedMultipleIndices = (draft.sourceIndices?.length ?? 0) > 1;
return (
<Form form={form} data-test-subj="fieldSelectionForm">
{!isLoading && hasSelectedMultipleIndices && matchFieldOptions.length === 0 && (
<>
<EuiCallOut
title={i18n.translate('xpack.idxMgmt.enrichPolicyCreate.noCommonFieldsFoundError', {
defaultMessage: 'No common fields',
})}
color="danger"
iconType="error"
data-test-subj="noCommonFieldsError"
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.matchFieldError"
defaultMessage="The selected indices don't have any fields in common."
/>
</EuiCallOut>
<EuiSpacer />
</>
)}
<UseField
path="matchField"
component={ComboBoxField}
labelAppend={
<EuiIconTip
data-test-subj="matchFieldPopover"
content={i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.matchFieldPopover',
{
defaultMessage:
'The field in your source indices to match with the incoming documents.',
}
)}
/>
}
componentProps={{
fullWidth: false,
helpText: hasSelectedMultipleIndices
? i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.matchFieldHelpText',
{
defaultMessage:
'The match field must be the same across all selected source indices.',
}
)
: undefined,
euiFieldProps: {
'data-test-subj': 'matchField',
placeholder: i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.matchFieldPlaceholder',
{
defaultMessage: 'Select a field',
}
),
noSuggestions: false,
singleSelection: { asPlainText: true },
options: matchFieldOptions,
},
}}
/>
<UseField
path="enrichFields"
component={ComboBoxField}
labelAppend={
<EuiIconTip
data-test-subj="enrichFieldsPopover"
content={i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.EnrichFieldsFieldPopover',
{
defaultMessage: 'The fields to add to your incoming documents.',
}
)}
/>
}
componentProps={{
fullWidth: false,
euiFieldProps: {
'data-test-subj': 'enrichFields',
placeholder: i18n.translate(
'xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.enrichFieldsPlaceholder',
{
defaultMessage: 'Select fields to enrich',
}
),
noSuggestions: false,
options: fieldOptions,
},
}}
/>
<EuiSpacer />
<EuiFlexGroup
data-test-subj="fieldSelectionStep"
justifyContent="spaceBetween"
style={{ maxWidth: 400 }}
>
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
iconSide="left"
iconType="arrowLeft"
data-test-subj="backButton"
onClick={onBack}
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.backButtonLabel"
defaultMessage="Back"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="primary"
iconSide="right"
iconType="arrowRight"
disabled={form.isValid === false}
data-test-subj="nextButton"
onClick={onSubmit}
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicyCreate.fieldSelectionStep.nextButtonLabel"
defaultMessage="Next"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</Form>
);
};

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useCallback, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { uniq, isEmpty } from 'lodash';
import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import type { EuiComboBoxProps } from '@elastic/eui';
import { getMatchingIndices } from '../../../../services/api';
import type { FieldHook } from '../../../../../shared_imports';
import { getFieldValidityAndErrorMessage } from '../../../../../shared_imports';
interface IOption {
label: string;
options: Array<{ value: string; label: string; key?: string }>;
}
interface Props {
field: FieldHook;
euiFieldProps: EuiComboBoxProps<string>;
[key: string]: any;
}
const getIndexOptions = async (patternString: string) => {
const options: IOption[] = [];
if (!patternString) {
return options;
}
const { data } = await getMatchingIndices(patternString);
const matchingIndices = data.indices;
if (matchingIndices.length) {
const matchingOptions = uniq([...matchingIndices]);
options.push({
label: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.indicesSelector.optionsLabel', {
defaultMessage: 'Based on your indices',
}),
options: matchingOptions
.map((match) => {
return {
label: match,
value: match,
};
})
.sort((a, b) => String(a.label).localeCompare(b.label)),
});
} else {
options.push({
label: i18n.translate('xpack.idxMgmt.enrichPolicyCreate.indicesSelector.noMatchingOption', {
defaultMessage: 'No indices match your search criteria.',
}),
options: [],
});
}
return options;
};
export const IndicesSelector = ({ field, euiFieldProps, ...rest }: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const [indexOptions, setIndexOptions] = useState<IOption[]>([]);
const [isIndiciesLoading, setIsIndiciesLoading] = useState<boolean>(false);
const onSearchChange = useCallback(
async (search: string) => {
const indexPattern = isEmpty(search) ? '*' : search;
setIsIndiciesLoading(true);
setIndexOptions(await getIndexOptions(indexPattern));
setIsIndiciesLoading(false);
},
[setIsIndiciesLoading, setIndexOptions]
);
// Fetch indices on mount so that the ComboBox has some initial options
useEffect(() => {
if (isEmpty(field.value)) {
onSearchChange('*');
}
}, [field.value, onSearchChange]);
return (
<EuiFormRow
label={field.label}
labelAppend={field.labelAppend}
helpText={typeof field.helpText === 'function' ? field.helpText() : field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
{...rest}
>
<EuiComboBox
async
isLoading={isIndiciesLoading}
options={indexOptions}
noSuggestions={!indexOptions.length}
selectedOptions={((field.value as string[]) || []).map((anIndex: string) => {
return {
label: anIndex,
value: anIndex,
};
})}
onChange={async (selected: EuiComboBoxOptionOption[]) => {
field.setValue(selected.map((aSelected) => aSelected.value) as string[]);
}}
onSearchChange={onSearchChange}
onBlur={() => {
if (!field.value) {
field.setValue([]);
}
}}
data-test-subj="comboBox"
{...euiFieldProps}
/>
</EuiFormRow>
);
};

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { ConfigurationStep } from './configuration';
export { FieldSelectionStep } from './field_selection';
export { CreateStep } from './create';

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState, useRef } from 'react';
import { EuiConfirmModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { deleteEnrichPolicy } from '../../../../services/api';
import { useAppContext } from '../../../../app_context';
export const DeletePolicyModal = ({
policyToDelete,
callback,
}: {
policyToDelete: string;
callback: (data?: { hasDeletedPolicy: boolean }) => void;
}) => {
const mounted = useRef(false);
const {
services: { notificationService },
} = useAppContext();
const [isDeleting, setIsDeleting] = useState(false);
// Since the async action of this component needs to set state after unmounting,
// we need to track the mounted state of this component to avoid a memory leak.
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const handleDeletePolicy = () => {
setIsDeleting(true);
deleteEnrichPolicy(policyToDelete)
.then(({ data, error }) => {
if (data) {
const successMessage = i18n.translate(
'xpack.idxMgmt.enrichPolicies.deleteModal.successDeleteNotificationMessage',
{ defaultMessage: 'Deleted {policyToDelete}', values: { policyToDelete } }
);
notificationService.showSuccessToast(successMessage);
return callback({ hasDeletedPolicy: true });
}
if (error) {
const errorMessage = i18n.translate(
'xpack.idxMgmt.enrichPolicies.deleteModal.errorDeleteNotificationMessage',
{
defaultMessage: "Error deleting enrich policy: '{error}'",
values: { error: error.message },
}
);
notificationService.showDangerToast(errorMessage);
}
callback();
})
.finally(() => {
if (mounted.current) {
setIsDeleting(false);
}
});
};
const handleOnCancel = () => {
callback();
};
return (
<EuiConfirmModal
buttonColor="danger"
data-test-subj="deletePolicyModal"
title={i18n.translate('xpack.idxMgmt.enrichPolicies.deleteModal.confirmTitle', {
defaultMessage: 'Delete enrich policy',
})}
onCancel={handleOnCancel}
onConfirm={handleDeletePolicy}
cancelButtonText={i18n.translate('xpack.idxMgmt.enrichPolicies.deleteModal.cancelButton', {
defaultMessage: 'Cancel',
})}
confirmButtonText={i18n.translate('xpack.idxMgmt.enrichPolicies.deleteModal.deleteButton', {
defaultMessage: 'Delete',
})}
confirmButtonDisabled={isDeleting}
>
<p>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.deleteModal.bodyCopy"
defaultMessage="You are about to delete the enrich policy {policy}. This action is irreversible."
values={{
policy: <strong>{policyToDelete}</strong>,
}}
/>
</p>
</EuiConfirmModal>
);
};

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState, useRef } from 'react';
import { EuiConfirmModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { executeEnrichPolicy } from '../../../../services/api';
import { useAppContext } from '../../../../app_context';
export const ExecutePolicyModal = ({
policyToExecute,
callback,
}: {
policyToExecute: string;
callback: (data?: { hasExecutedPolicy: boolean }) => void;
}) => {
const mounted = useRef(false);
const {
services: { notificationService },
} = useAppContext();
const [isExecuting, setIsExecuting] = useState(false);
// Since the async action of this component needs to set state after unmounting,
// we need to track the mounted state of this component to avoid a memory leak.
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const handleExecutePolicy = () => {
setIsExecuting(true);
executeEnrichPolicy(policyToExecute)
.then(({ data, error }) => {
if (data) {
const successMessage = i18n.translate(
'xpack.idxMgmt.enrichPolicies.executeModal.successExecuteNotificationMessage',
{ defaultMessage: 'Executed {policyToExecute}', values: { policyToExecute } }
);
notificationService.showSuccessToast(successMessage);
return callback({ hasExecutedPolicy: true });
}
if (error) {
const errorMessage = i18n.translate(
'xpack.idxMgmt.enrichPolicies.executeModal.errorExecuteNotificationMessage',
{
defaultMessage: "Error executing enrich policy: '{error}'",
values: { error: error.message },
}
);
notificationService.showDangerToast(errorMessage);
}
callback();
})
.finally(() => {
if (mounted.current) {
setIsExecuting(false);
}
});
};
const handleOnCancel = () => {
callback();
};
return (
<EuiConfirmModal
data-test-subj="executePolicyModal"
title={i18n.translate('xpack.idxMgmt.enrichPolicies.executeModal.confirmTitle', {
defaultMessage: 'Execute enrich policy',
})}
onCancel={handleOnCancel}
onConfirm={handleExecutePolicy}
cancelButtonText={i18n.translate('xpack.idxMgmt.enrichPolicies.executeModal.cancelButton', {
defaultMessage: 'Cancel',
})}
confirmButtonText={i18n.translate('xpack.idxMgmt.enrichPolicies.executeModal.executeButton', {
defaultMessage: 'Execute',
})}
confirmButtonDisabled={isExecuting}
>
<p>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.executeModal.bodyCopy"
defaultMessage="You are about to execute the enrich policy {policy}."
values={{
policy: <strong>{policyToExecute}</strong>,
}}
/>
</p>
</EuiConfirmModal>
);
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { DeletePolicyModal } from './delete_policy_modal';
export { ExecutePolicyModal } from './execute_policy_modal';

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { PolicyDetailsFlyout } from './policy_details_flyout';

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiTitle,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
} from '@elastic/eui';
import { CodeEditor } from '@kbn/kibana-react-plugin/public';
import type { SerializedEnrichPolicy } from '../../../../../../common';
export interface Props {
policy: SerializedEnrichPolicy;
onClose: () => void;
}
export const PolicyDetailsFlyout: FunctionComponent<Props> = ({ policy, onClose }) => {
return (
<EuiFlyout onClose={onClose} data-test-subj="policyDetailsFlyout" size="m" maxWidth={550}>
<EuiFlyoutHeader>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiTitle id="policyDetailsFlyoutTitle" data-test-subj="title">
<h2>{policy.name}</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiDescriptionList>
{/* Policy type */}
{policy.type && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicies.detailsFlyout.typeTitle', {
defaultMessage: 'Type',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="policyTypeValue">
{policy.type}
</EuiDescriptionListDescription>
</>
)}
{/* Policy source indices */}
{policy.sourceIndices && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicies.detailsFlyout.sourceIndicesTitle', {
defaultMessage: 'Source indices',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="policyIndicesValue">
<span className="eui-textBreakWord">{policy.sourceIndices.join(', ')}</span>
</EuiDescriptionListDescription>
</>
)}
{/* Policy match field */}
{policy.matchField && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicies.detailsFlyout.matchFieldTitle', {
defaultMessage: 'Match field',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="policyMatchFieldValue">
{policy.matchField}
</EuiDescriptionListDescription>
</>
)}
{/* Policy enrich fields */}
{policy.enrichFields && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicies.detailsFlyout.enrichFieldsTitle', {
defaultMessage: 'Enrich fields',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="policyEnrichFieldsValue">
<span className="eui-textBreakWord">{policy.enrichFields.join(', ')}</span>
</EuiDescriptionListDescription>
</>
)}
{/* Policy query */}
{policy.query && (
<>
<EuiDescriptionListTitle>
{i18n.translate('xpack.idxMgmt.enrichPolicies.detailsFlyout.queryTitle', {
defaultMessage: 'Query',
})}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<CodeEditor
languageId="json"
isCopyable
allowFullScreen
value={JSON.stringify(policy.query, null, 2)}
data-test-subj="queryEditor"
height={250}
options={{
lineNumbers: 'off',
tabSize: 2,
automaticLayout: true,
}}
aria-label={i18n.translate(
'xpack.idxMgmt.enrichPolicies.detailsFlyout.queryAriaLabel',
{ defaultMessage: 'Enrich policy query editor' }
)}
/>
</EuiDescriptionListDescription>
</>
)}
</EuiDescriptionList>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={onClose}
flush="left"
data-test-subj="closeFlyoutButton"
>
{i18n.translate('xpack.idxMgmt.enrichPolicies.detailsFlyout.closeButton', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useAppContext } from '../../../../app_context';
export const EmptyState = () => {
const { history } = useAppContext();
return (
<KibanaPageTemplate.EmptyPrompt
iconType="managementApp"
data-test-subj="sectionEmpty"
title={
<h2>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.list.emptyPromptTitle"
defaultMessage="Add your first enrich policy"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.list.emptyPromptDescription"
defaultMessage="Use an enrich policy to add data from existing indices into incoming documents during ingest."
/>
</p>
}
actions={
<EuiButton
fill
iconType="plusInCircle"
data-test-subj="enrichPoliciesEmptyPromptCreateButton"
{...reactRouterNavigate(history, '/enrich_policies/create')}
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.list.emptyPromptButtonLabel"
defaultMessage="Add an enrich policy"
/>
</EuiButton>
}
/>
);
};

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButton, EuiPageTemplate, EuiSpacer, EuiText } from '@elastic/eui';
import { useLoadEnrichPolicies } from '../../../../services/api';
export const ErrorState = ({
error,
resendRequest,
}: {
error: {
error: string;
message?: string;
};
resendRequest: ReturnType<typeof useLoadEnrichPolicies>['resendRequest'];
}) => {
return (
<EuiPageTemplate.EmptyPrompt
color="danger"
iconType="error"
data-test-subj="sectionError"
title={
<h2>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.list.errorTitle"
defaultMessage="Unable to load enrich policies"
/>
</h2>
}
body={
<>
<EuiText color="subdued">
<p className="eui-textBreakWord">{error?.message || error?.error}</p>
</EuiText>
<EuiSpacer />
<EuiButton iconSide="right" onClick={resendRequest} iconType="refresh" color="danger">
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.list.errorReloadButton"
defaultMessage="Reload"
/>
</EuiButton>
</>
}
/>
);
};

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { ErrorState } from './error_state';
export { LoadingState } from './loading_state';
export { EmptyState } from './empty_state';

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiLoadingSpinner, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
export const LoadingState = () => {
return (
<KibanaPageTemplate.EmptyPrompt
title={<EuiLoadingSpinner size="xl" />}
body={
<EuiText color="subdued">
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.list.loadingStateLabel"
defaultMessage="Loading enrich policies…"
/>
</EuiText>
}
data-test-subj="sectionLoading"
/>
);
};

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiSpacer, EuiText, EuiLink } from '@elastic/eui';
import { RouteComponentProps } from 'react-router-dom';
import { Location } from 'history';
import { parse } from 'query-string';
import { APP_WRAPPER_CLASS, useExecutionContext } from '../../../../shared_imports';
import type { SerializedEnrichPolicy } from '../../../../../common';
import { useAppContext } from '../../../app_context';
import { useRedirectPath } from '../../../hooks/redirect_path';
import { breadcrumbService, IndexManagementBreadcrumb } from '../../../services/breadcrumbs';
import { documentationService } from '../../../services/documentation';
import { useLoadEnrichPolicies } from '../../../services/api';
import { PoliciesTable } from './policies_table';
import { DeletePolicyModal, ExecutePolicyModal } from './confirm_modals';
import { LoadingState, ErrorState, EmptyState } from './empty_states';
import { PolicyDetailsFlyout } from './details_flyout';
const getEnrichPolicyNameFromLocation = (location: Location) => {
const { policy } = parse(location.search.substring(1));
return policy;
};
export const EnrichPoliciesList: React.FunctionComponent<RouteComponentProps> = ({
history,
location,
}) => {
const {
core: { executionContext },
} = useAppContext();
const redirectTo = useRedirectPath(history);
useEffect(() => {
breadcrumbService.setBreadcrumbs(IndexManagementBreadcrumb.enrichPolicies);
}, []);
useExecutionContext(executionContext, {
type: 'application',
page: 'indexManagementEnrichPoliciesTab',
});
// Policy details flyout
const enrichPolicyNameFromLocation = getEnrichPolicyNameFromLocation(location);
const [showFlyoutFor, setShowFlyoutFor] = useState<SerializedEnrichPolicy | undefined>();
// Policy table actions
const [policyToDelete, setPolicyToDelete] = useState<string | undefined>();
const [policyToExecute, setPolicyToExecute] = useState<string | undefined>();
const {
error,
isLoading,
data: policies,
resendRequest: reloadPolicies,
} = useLoadEnrichPolicies();
useEffect(() => {
if (enrichPolicyNameFromLocation && policies?.length) {
const policy = policies.find((p) => p.name === enrichPolicyNameFromLocation);
setShowFlyoutFor(policy);
}
}, [enrichPolicyNameFromLocation, policies]);
if (isLoading) {
return <LoadingState />;
}
if (error) {
return <ErrorState error={error} resendRequest={reloadPolicies} />;
}
if (policies?.length === 0) {
return <EmptyState />;
}
return (
<div className={APP_WRAPPER_CLASS} data-test-subj="enrichPoliciesList">
<EuiText color="subdued">
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.list.descriptionTitle"
defaultMessage="Use an enrich policy to automatically enhance your incoming documents with data from your existing indices. {learnMoreLink}"
values={{
learnMoreLink: (
<EuiLink
href={documentationService.getEnrichApisLink()}
target="_blank"
external
data-test-subj="enrichPoliciesLearnMoreLink"
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.list.docsLink"
defaultMessage="Learn more"
/>
</EuiLink>
),
}}
/>
</EuiText>
<EuiSpacer size="l" />
<PoliciesTable
policies={policies as SerializedEnrichPolicy[]}
onReloadClick={reloadPolicies}
onDeletePolicyClick={setPolicyToDelete}
onExecutePolicyClick={setPolicyToExecute}
/>
{policyToDelete && (
<DeletePolicyModal
policyToDelete={policyToDelete}
callback={(deleteResponse) => {
if (deleteResponse?.hasDeletedPolicy) {
reloadPolicies();
}
setPolicyToDelete(undefined);
}}
/>
)}
{policyToExecute && (
<ExecutePolicyModal
policyToExecute={policyToExecute}
callback={(executeResponse) => {
if (executeResponse?.hasExecutedPolicy) {
reloadPolicies();
}
setPolicyToExecute(undefined);
}}
/>
)}
{showFlyoutFor && (
<PolicyDetailsFlyout
policy={showFlyoutFor}
onClose={() => {
setShowFlyoutFor(undefined);
redirectTo('/enrich_policies');
}}
/>
)}
</div>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { EnrichPoliciesList } from './enrich_policies_list';

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { PoliciesTable } from './policies_table';

View file

@ -0,0 +1,175 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import {
EuiInMemoryTable,
EuiBasicTableColumn,
EuiSearchBarProps,
EuiButton,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public';
import { useAppContext } from '../../../../app_context';
import type { SerializedEnrichPolicy } from '../../../../../../common/types';
export interface Props {
policies: SerializedEnrichPolicy[];
onReloadClick: () => void;
onDeletePolicyClick: (policyName: string) => void;
onExecutePolicyClick: (policyName: string) => void;
}
const pagination = {
initialPageSize: 50,
pageSizeOptions: [25, 50, 100],
};
export const PoliciesTable: FunctionComponent<Props> = ({
policies,
onReloadClick,
onDeletePolicyClick,
onExecutePolicyClick,
}) => {
const { history } = useAppContext();
const renderToolsRight = () => {
return [
<EuiButton
key="reloadPolicies"
data-test-subj="reloadPoliciesButton"
iconType="refresh"
color="success"
onClick={onReloadClick}
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.table.reloadButton"
defaultMessage="Reload"
/>
</EuiButton>,
<EuiButton
key="createPolicy"
fill
iconType="plusInCircle"
{...reactRouterNavigate(history, '/enrich_policies/create')}
>
<FormattedMessage
id="xpack.idxMgmt.enrichPolicies.table.createPolicyButton"
defaultMessage="Create enrich policy"
/>
</EuiButton>,
];
};
const search: EuiSearchBarProps = {
toolsRight: renderToolsRight(),
box: {
incremental: true,
},
};
const columns: Array<EuiBasicTableColumn<SerializedEnrichPolicy>> = [
{
field: 'name',
name: i18n.translate('xpack.idxMgmt.enrichPolicies.table.nameField', {
defaultMessage: 'Name',
}),
sortable: true,
truncateText: true,
render: (name: string) => (
<EuiLink
data-test-subj="enrichPolicyDetailsLink"
{...reactRouterNavigate(history, {
pathname: '/enrich_policies',
search: `policy=${encodeURIComponent(name)}`,
})}
>
{name}
</EuiLink>
),
},
{
field: 'type',
name: i18n.translate('xpack.idxMgmt.enrichPolicies.table.typeField', {
defaultMessage: 'Type',
}),
sortable: true,
},
{
field: 'sourceIndices',
name: i18n.translate('xpack.idxMgmt.enrichPolicies.table.sourceIndicesField', {
defaultMessage: 'Source indices',
}),
truncateText: true,
render: (indices: string[]) => <span className="eui-textTruncate">{indices.join(', ')}</span>,
},
{
field: 'matchField',
name: i18n.translate('xpack.idxMgmt.enrichPolicies.table.matchFieldField', {
defaultMessage: 'Match field',
}),
truncateText: true,
},
{
field: 'enrichFields',
name: i18n.translate('xpack.idxMgmt.enrichPolicies.table.enrichFieldsField', {
defaultMessage: 'Enrich fields',
}),
truncateText: true,
render: (fields: string[]) => <span className="eui-textTruncate">{fields.join(', ')}</span>,
},
{
name: i18n.translate('xpack.idxMgmt.enrichPolicies.table.actionsField', {
defaultMessage: 'Actions',
}),
actions: [
{
isPrimary: true,
name: i18n.translate('xpack.idxMgmt.enrichPolicies.table.executeAction', {
defaultMessage: 'Execute',
}),
description: i18n.translate('xpack.idxMgmt.enrichPolicies.table.executeDescription', {
defaultMessage: 'Execute this enrich policy',
}),
type: 'icon',
icon: 'play',
'data-test-subj': 'executePolicyButton',
onClick: ({ name }) => onExecutePolicyClick(name),
},
{
isPrimary: true,
name: i18n.translate('xpack.idxMgmt.enrichPolicies.table.deleteAction', {
defaultMessage: 'Delete',
}),
description: i18n.translate('xpack.idxMgmt.enrichPolicies.table.deleteDescription', {
defaultMessage: 'Delete this enrich policy',
}),
type: 'icon',
icon: 'trash',
color: 'danger',
'data-test-subj': 'deletePolicyButton',
onClick: ({ name }) => onDeletePolicyClick(name),
},
],
},
];
return (
<EuiInMemoryTable
data-test-subj="enrichPoliciesTable"
items={policies}
itemId="name"
columns={columns}
search={search}
pagination={pagination}
sorting={true}
isSelectable={false}
/>
);
};

View file

@ -15,6 +15,7 @@ import { documentationService } from '../../services/documentation';
import { useAppContext } from '../../app_context';
import { ComponentTemplateList } from '../../components/component_templates';
import { IndexList } from './index_list';
import { EnrichPoliciesList } from './enrich_policies_list';
import { IndexDetailsPage } from './index_list/details_page';
import { DataStreamList } from './data_stream_list';
import { TemplateList } from './template_list';
@ -24,6 +25,7 @@ export enum Section {
DataStreams = 'data_streams',
IndexTemplates = 'templates',
ComponentTemplates = 'component_templates',
EnrichPolicies = 'enrich_policies',
}
export const homeSections = [
@ -31,6 +33,7 @@ export const homeSections = [
Section.DataStreams,
Section.IndexTemplates,
Section.ComponentTemplates,
Section.EnrichPolicies,
];
interface MatchParams {
@ -78,6 +81,15 @@ export const IndexManagementHome: React.FunctionComponent<RouteComponentProps<Ma
/>
),
},
{
id: Section.EnrichPolicies,
name: (
<FormattedMessage
id="xpack.idxMgmt.home.enrichPoliciesTabTitle"
defaultMessage="Enrich Policies"
/>
),
},
];
const onSectionChange = (newSection: Section) => {
@ -142,6 +154,7 @@ export const IndexManagementHome: React.FunctionComponent<RouteComponentProps<Ma
]}
component={ComponentTemplateList}
/>
<Route exact path={`/${Section.EnrichPolicies}`} component={EnrichPoliciesList} />
</Routes>
</>
);

View file

@ -9,6 +9,7 @@ import { METRIC_TYPE } from '@kbn/analytics';
import { IndicesStatsResponse } from '@elastic/elasticsearch/lib/api/types';
import {
API_BASE_PATH,
INTERNAL_API_BASE_PATH,
UIM_UPDATE_SETTINGS,
UIM_INDEX_CLEAR_CACHE,
UIM_INDEX_CLEAR_CACHE_MANY,
@ -32,7 +33,6 @@ import {
UIM_TEMPLATE_UPDATE,
UIM_TEMPLATE_CLONE,
UIM_TEMPLATE_SIMULATE,
INTERNAL_API_BASE_PATH,
} from '../../../common/constants';
import {
TemplateDeserialized,
@ -45,6 +45,7 @@ import { TAB_SETTINGS, TAB_MAPPING, TAB_STATS } from '../constants';
import { useRequest, sendRequest } from './use_request';
import { httpService } from './http';
import { UiMetricService } from './ui_metric';
import type { SerializedEnrichPolicy, FieldFromIndicesRequest } from '../../../common';
interface ReloadIndicesOptions {
asSystemRequest?: boolean;
@ -320,6 +321,67 @@ export function useLoadNodesPlugins() {
});
}
export const useLoadEnrichPolicies = () => {
return useRequest<SerializedEnrichPolicy[]>({
path: `${INTERNAL_API_BASE_PATH}/enrich_policies`,
method: 'get',
});
};
export async function deleteEnrichPolicy(policyName: string) {
const result = sendRequest({
path: `${INTERNAL_API_BASE_PATH}/enrich_policies/${policyName}`,
method: 'delete',
});
return result;
}
export async function executeEnrichPolicy(policyName: string) {
const result = sendRequest({
path: `${INTERNAL_API_BASE_PATH}/enrich_policies/${policyName}`,
method: 'put',
});
return result;
}
export async function createEnrichPolicy(
policy: SerializedEnrichPolicy,
executePolicyAfterCreation?: boolean
) {
const result = sendRequest({
path: `${INTERNAL_API_BASE_PATH}/enrich_policies`,
method: 'post',
body: JSON.stringify({ policy }),
query: {
executePolicyAfterCreation,
},
});
return result;
}
export async function getMatchingIndices(pattern: string) {
const result = sendRequest({
path: `${INTERNAL_API_BASE_PATH}/enrich_policies/get_matching_indices`,
method: 'post',
body: JSON.stringify({ pattern }),
});
return result;
}
export async function getFieldsFromIndices(indices: string[]) {
const result = sendRequest<FieldFromIndicesRequest>({
path: `${INTERNAL_API_BASE_PATH}/enrich_policies/get_fields_from_indices`,
method: 'post',
body: JSON.stringify({ indices }),
});
return result;
}
export function loadIndex(indexName: string) {
return sendRequest<Index>({
path: `${INTERNAL_API_BASE_PATH}/indices/${encodeURIComponent(indexName)}`,

View file

@ -10,6 +10,14 @@ import { ManagementAppMountParams } from '@kbn/management-plugin/public';
type SetBreadcrumbs = ManagementAppMountParams['setBreadcrumbs'];
export enum IndexManagementBreadcrumb {
/**
* Enrich policies tab
*/
enrichPolicies = 'enrichPolicies',
enrichPoliciesCreate = 'enrichPoliciesCreate',
}
class BreadcrumbService {
private breadcrumbs: {
[key: string]: Array<{
@ -69,6 +77,26 @@ class BreadcrumbService {
}),
},
];
this.breadcrumbs.enrichPolicies = [
...this.breadcrumbs.home,
{
text: i18n.translate('xpack.idxMgmt.breadcrumb.enrichPolicyLabel', {
defaultMessage: 'Enrich policies',
}),
href: `/enrich_policies`,
},
];
this.breadcrumbs.enrichPoliciesCreate = [
...this.breadcrumbs.enrichPolicies,
{
text: i18n.translate('xpack.idxMgmt.breadcrumb.enrichPolicyCreateLabel', {
defaultMessage: 'Create enrich policy',
}),
href: `/enrich_policies/create`,
},
];
}
public setBreadcrumbs(type: string): void {

View file

@ -14,6 +14,9 @@ class DocumentationService {
private dataStreams: string = '';
private esDocsBase: string = '';
private enrichPolicies: string = '';
private createEnrichPolicies: string = '';
private matchAllQuery: string = '';
private indexManagement: string = '';
private indexSettings: string = '';
private indexTemplates: string = '';
@ -67,6 +70,9 @@ class DocumentationService {
this.dataStreams = links.elasticsearch.dataStreams;
this.esDocsBase = links.elasticsearch.docsBase;
this.enrichPolicies = links.elasticsearch.enrichPolicies;
this.createEnrichPolicies = links.elasticsearch.createEnrichPolicy;
this.matchAllQuery = links.elasticsearch.matchAllQuery;
this.indexManagement = links.management.indexManagement;
this.indexSettings = links.elasticsearch.indexSettings;
this.indexTemplates = links.elasticsearch.indexTemplates;
@ -173,6 +179,18 @@ class DocumentationService {
return this.mappingRankFeatureFields;
}
public getEnrichApisLink() {
return this.enrichPolicies;
}
public getCreateEnrichPolicyLink() {
return this.createEnrichPolicies;
}
public getMatchAllQueryLink() {
return this.matchAllQuery;
}
public getMetaFieldLink() {
return this.mappingMetaFields;
}

View file

@ -28,7 +28,12 @@ export {
EuiCodeEditor,
} from '@kbn/es-ui-shared-plugin/public';
export type { FormSchema, FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
export type {
FormSchema,
FieldConfig,
FieldHook,
FieldValidateResponse,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
export {
FIELD_TYPES,
@ -39,6 +44,7 @@ export {
getUseField,
UseField,
FormDataProvider,
getFieldValidityAndErrorMessage,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
export {
@ -50,8 +56,12 @@ export {
export {
getFormRow,
Field,
FormRow,
TextField,
SelectField,
ToggleField,
JsonEditorField,
ComboBoxField,
} from '@kbn/es-ui-shared-plugin/static/forms/components';
export { isJSON } from '@kbn/es-ui-shared-plugin/static/validators/string';

View file

@ -6,24 +6,9 @@
*/
import { IScopedClusterClient } from '@kbn/core/server';
import type { EnrichSummary, EnrichPolicyType } from '@elastic/elasticsearch/lib/api/types';
import type { EnrichSummary } from '@elastic/elasticsearch/lib/api/types';
import type { SerializedEnrichPolicy } from '../../common/types';
const getPolicyType = (policy: EnrichSummary): EnrichPolicyType => {
if (policy.config.match) {
return 'match';
}
if (policy.config.geo_match) {
return 'geo_match';
}
if (policy.config.range) {
return 'range';
}
throw new Error('Unknown policy type');
};
import { getPolicyType } from '../../common/lib';
export const serializeEnrichmentPolicies = (
policies: EnrichSummary[]
@ -37,6 +22,7 @@ export const serializeEnrichmentPolicies = (
sourceIndices: policy.config[policyType].indices,
matchField: policy.config[policyType].match_field,
enrichFields: policy.config[policyType].enrich_fields,
query: policy.config[policyType].query,
};
});
};
@ -47,6 +33,28 @@ const fetchAll = async (client: IScopedClusterClient) => {
return serializeEnrichmentPolicies(res.policies);
};
const create = (
client: IScopedClusterClient,
policyName: string,
serializedPolicy: EnrichSummary['config']
) => {
return client.asCurrentUser.enrich.putPolicy({
name: policyName,
...serializedPolicy,
});
};
const execute = (client: IScopedClusterClient, policyName: string) => {
return client.asCurrentUser.enrich.executePolicy({ name: policyName });
};
const remove = (client: IScopedClusterClient, policyName: string) => {
return client.asCurrentUser.enrich.deletePolicy({ name: policyName });
};
export const enrichPoliciesActions = {
fetchAll,
create,
execute,
remove,
};

View file

@ -58,4 +58,312 @@ describe('Enrich policies API', () => {
await expect(router.runRequest(mockRequest)).rejects.toThrowError(error);
});
});
describe('Execute policy - PUT /api/index_management/enrich_policies/{policy}', () => {
const executeEnrichPolicy = router.getMockESApiFn('enrich.executePolicy');
it('correctly executes a policy', async () => {
const mockRequest: RequestMock = {
method: 'put',
path: addInternalBasePath('/enrich_policies/{name}'),
params: {
name: 'my-policy',
},
};
executeEnrichPolicy.mockResolvedValue({ status: { phase: 'COMPLETE' } });
const res = await router.runRequest(mockRequest);
expect(res).toEqual({
body: { status: { phase: 'COMPLETE' } },
});
});
it('should return an error if it fails', async () => {
const mockRequest: RequestMock = {
method: 'put',
path: addInternalBasePath('/enrich_policies/{name}'),
params: {
name: 'my-policy',
},
};
const error = new Error('Oh no!');
executeEnrichPolicy.mockRejectedValue(error);
await expect(router.runRequest(mockRequest)).rejects.toThrowError(error);
});
});
describe('Delete policy - DELETE /api/index_management/enrich_policies/{policy}', () => {
const deleteEnrichPolicy = router.getMockESApiFn('enrich.deletePolicy');
it('correctly deletes a policy', async () => {
const mockRequest: RequestMock = {
method: 'delete',
path: addInternalBasePath('/enrich_policies/{name}'),
params: {
name: 'my-policy',
},
};
deleteEnrichPolicy.mockResolvedValue({ status: { phase: 'COMPLETE' } });
const res = await router.runRequest(mockRequest);
expect(res).toEqual({
body: { status: { phase: 'COMPLETE' } },
});
});
it('should return an error if it fails', async () => {
const mockRequest: RequestMock = {
method: 'delete',
path: addInternalBasePath('/enrich_policies/{name}'),
params: {
name: 'my-policy',
},
};
const error = new Error('Oh no!');
deleteEnrichPolicy.mockRejectedValue(error);
await expect(router.runRequest(mockRequest)).rejects.toThrowError(error);
});
});
describe('Create policy - POST /api/index_management/enrich_policies', () => {
const createPolicyMock = router.getMockESApiFn('enrich.putPolicy');
const executePolicyMock = router.getMockESApiFn('enrich.executePolicy');
const deletePolicyMock = router.getMockESApiFn('enrich.deletePolicy');
it('correctly creates a policy', async () => {
const mockRequest: RequestMock = {
method: 'post',
path: addInternalBasePath('/enrich_policies'),
body: {
policy: {
name: 'my-policy',
type: 'match',
matchField: 'my_field',
enrichFields: ['field_1', 'field_2'],
sourceIndex: ['index_1'],
},
},
};
createPolicyMock.mockResolvedValue({ status: { status: 'OK' } });
const res = await router.runRequest(mockRequest);
expect(res).toEqual({
body: { status: { status: 'OK' } },
});
expect(executePolicyMock).not.toHaveBeenCalled();
});
it('can create a policy and execute it', async () => {
const mockRequest: RequestMock = {
method: 'post',
path: addInternalBasePath('/enrich_policies'),
query: {
executePolicyAfterCreation: true,
},
body: {
policy: {
name: 'my-policy',
type: 'match',
matchField: 'my_field',
enrichFields: ['field_1', 'field_2'],
sourceIndex: ['index_1'],
},
},
};
createPolicyMock.mockResolvedValue({ status: { status: 'OK' } });
executePolicyMock.mockResolvedValue({ status: { status: 'OK' } });
const res = await router.runRequest(mockRequest);
expect(res).toEqual({
body: { status: { status: 'OK' } },
});
expect(executePolicyMock).toHaveBeenCalled();
});
it('if when creating policy and executing the execution fails, the policy should be removed', async () => {
const mockRequest: RequestMock = {
method: 'post',
path: addInternalBasePath('/enrich_policies'),
query: {
executePolicyAfterCreation: true,
},
body: {
policy: {
name: 'my-policy',
type: 'match',
matchField: 'my_field',
enrichFields: ['field_1', 'field_2'],
sourceIndex: ['index_1'],
},
},
};
createPolicyMock.mockResolvedValue({ status: { status: 'OK' } });
const executeError = new Error('Oh no!');
executePolicyMock.mockRejectedValue(executeError);
deletePolicyMock.mockResolvedValue({ status: { status: 'OK' } });
// Expect the API to fail and the policy to be deleted
await expect(router.runRequest(mockRequest)).rejects.toThrowError(executeError);
expect(deletePolicyMock).toHaveBeenCalled();
});
it('should return an error if it fails', async () => {
const mockRequest: RequestMock = {
method: 'post',
path: addInternalBasePath('/enrich_policies'),
body: {
policy: {
name: 'my-policy',
type: 'match',
matchField: 'my_field',
enrichFields: ['field_1', 'field_2'],
sourceIndex: ['index_1'],
},
},
};
const error = new Error('Oh no!');
createPolicyMock.mockRejectedValue(error);
await expect(router.runRequest(mockRequest)).rejects.toThrowError(error);
});
});
describe('Fields from indices - POST /api/index_management/enrich_policies/get_fields_from_indices', () => {
const fieldCapsMock = router.getMockESApiFn('fieldCaps');
it('correctly returns fields and common fields for the selected indices', async () => {
const mockRequest: RequestMock = {
method: 'post',
path: addInternalBasePath('/enrich_policies/get_fields_from_indices'),
body: {
indices: ['test-a', 'test-b'],
},
};
fieldCapsMock.mockResolvedValue({
body: {
indices: ['test-a'],
fields: {
name: { text: { type: 'text' } },
},
},
statusCode: 200,
});
const res = await router.runRequest(mockRequest);
expect(res).toEqual({
body: {
indices: [
{
index: 'test-a',
fields: [{ name: 'name', type: 'text', normalizedType: 'text' }],
},
{
index: 'test-b',
fields: [{ name: 'name', type: 'text', normalizedType: 'text' }],
},
],
commonFields: [{ name: 'name', type: 'text', normalizedType: 'text' }],
},
});
expect(fieldCapsMock).toHaveBeenCalled();
});
it('should return an error if it fails', async () => {
const mockRequest: RequestMock = {
method: 'post',
path: addInternalBasePath('/enrich_policies/get_fields_from_indices'),
body: {
indices: ['test-a'],
},
};
const error = new Error('Oh no!');
fieldCapsMock.mockRejectedValue(error);
await expect(router.runRequest(mockRequest)).rejects.toThrowError(error);
});
});
describe('Get matching indices - POST /api/index_management/enrich_policies/get_matching_indices', () => {
const getAliasMock = router.getMockESApiFn('indices.getAlias');
const searchMock = router.getMockESApiFn('search');
it('Return matching indices using alias api', async () => {
const mockRequest: RequestMock = {
method: 'post',
path: addInternalBasePath('/enrich_policies/get_matching_indices'),
body: {
pattern: 'test',
},
};
getAliasMock.mockResolvedValue({
body: {},
statusCode: 200,
});
const res = await router.runRequest(mockRequest);
expect(res).toEqual({
body: {
indices: [],
},
});
expect(searchMock).not.toHaveBeenCalled();
expect(getAliasMock).toHaveBeenCalledWith(
{ index: '*test*', expand_wildcards: 'open' },
{ ignore: [404], meta: true }
);
});
it('When alias api fails or returns nothing it fallsback to search api', async () => {
const mockRequest: RequestMock = {
method: 'post',
path: addInternalBasePath('/enrich_policies/get_matching_indices'),
body: {
pattern: 'test',
},
};
getAliasMock.mockResolvedValue({
body: {},
statusCode: 404,
});
searchMock.mockResolvedValue({
body: {},
statusCode: 404,
});
const res = await router.runRequest(mockRequest);
expect(res).toEqual({
body: {
indices: [],
},
});
expect(searchMock).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getCommonFields, normalizeFieldsList } from './helpers';
const commonField = { name: 'name', type: 'keyword', normalizedType: 'keyword' };
const fieldsPerIndexMock = [
{
index: 'index1',
fields: [commonField, { name: 'age', type: 'long', normalizedType: 'number' }],
},
{
index: 'index2',
fields: [commonField, { name: 'email', type: 'keyword', normalizedType: 'keyword' }],
},
];
describe('getCommonFields', () => {
it('should return common fields', () => {
expect(getCommonFields(fieldsPerIndexMock)).toEqual([commonField]);
});
it('should return empty array if it has no common fields', () => {
const mock = [
{
index: 'index1',
fields: [{ name: 'age', type: 'long', normalizedType: 'number' }],
},
];
expect(getCommonFields(mock)).toEqual([]);
});
});
describe('normalizeFieldsList', () => {
it('knows how to normalize types', () => {
const mock = {
age: {
long: {
type: 'long',
metadata_field: false,
searchable: false,
aggregatable: false,
},
},
ignore: {
_ignore: {
type: '_ignore',
metadata_field: false,
searchable: false,
aggregatable: false,
},
},
};
expect(normalizeFieldsList(mock)).toEqual([
{
name: 'age',
type: 'long',
normalizedType: 'number',
},
]);
});
});

View file

@ -0,0 +1,146 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { forEach, keys, sortBy, reduce, size } from 'lodash';
import { flatMap, flow, groupBy, values as valuesFP, map, pickBy } from 'lodash/fp';
import type { IScopedClusterClient } from '@kbn/core/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { FieldCapsResponse } from '@elastic/elasticsearch/lib/api/types';
export type FieldCapsList = FieldCapsResponse['fields'];
const normalizedFieldTypes: { [key: string]: string } = {
long: 'number',
integer: 'number',
short: 'number',
byte: 'number',
double: 'number',
float: 'number',
half_float: 'number',
scaled_float: 'number',
};
interface FieldItem {
name: string;
type: string;
normalizedType: string;
}
interface FieldsPerIndexType {
index: string;
fields: FieldItem[];
}
interface IndicesAggs extends estypes.AggregationsMultiBucketAggregateBase {
buckets: Array<{ key: unknown }>;
}
export function getCommonFields(fieldsPerIndex: FieldsPerIndexType[]) {
return flow(
// Flatten the fields arrays
flatMap('fields'),
// Group fields by name
groupBy('name'),
// Keep groups with more than 1 field
pickBy((group) => group.length > 1),
// Convert the result object to an array of fields
valuesFP,
// Take the first item from each group (since we only need one match)
map((group) => group[0])
)(fieldsPerIndex);
}
export function normalizeFieldsList(fields: FieldCapsList) {
const result: FieldItem[] = [];
forEach(fields, (field, name) => {
// If the field exists in multiple indexes, the types may be inconsistent.
// In this case, default to the first type.
const type = keys(field)[0];
// Do not include fields that have a type that starts with an underscore (e.g. _id, _source)
if (type.startsWith('_')) {
return;
}
result.push({
name,
type,
normalizedType: normalizedFieldTypes[type] || type,
});
});
return sortBy(result, 'name');
}
export function getIndexNamesFromAliasesResponse(json: Record<string, any>) {
return reduce(
json,
(list, { aliases }, indexName) => {
// Add the index name to the list
list.push(indexName);
// If the index has aliases, add them to the list as well
if (size(aliases) > 0) {
list.push(...Object.keys(aliases));
}
return list;
},
[] as string[]
);
}
export async function getIndices(dataClient: IScopedClusterClient, pattern: string, limit = 10) {
// We will first rely on the indices aliases API to get the list of indices and their aliases.
const aliasResult = await dataClient.asCurrentUser.indices.getAlias(
{
index: pattern,
expand_wildcards: 'open',
},
{
ignore: [404],
meta: true,
}
);
if (aliasResult.statusCode !== 404) {
const indicesFromAliasResponse = getIndexNamesFromAliasesResponse(aliasResult.body);
return indicesFromAliasResponse.slice(0, limit);
}
// If the indices aliases API fails or returns nothing, we will rely on the indices stats API to
// get the list of indices.
const response = await dataClient.asCurrentUser.search<unknown, { indices: IndicesAggs }>(
{
index: pattern,
body: {
size: 0,
aggs: {
indices: {
terms: {
field: '_index',
size: limit,
},
},
},
},
},
{
ignore: [404],
meta: true,
}
);
if (response.statusCode === 404 || !response.body.aggregations) {
return [];
}
const indices = response.body.aggregations.indices;
return indices.buckets ? indices.buckets.map((bucket) => bucket.key) : [];
}

View file

@ -0,0 +1,155 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IScopedClusterClient } from '@kbn/core/server';
import { schema, TypeOf } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addInternalBasePath } from '..';
import { enrichPoliciesActions } from '../../../lib/enrich_policies';
import { serializeAsESPolicy } from '../../../../common/lib';
import { normalizeFieldsList, getIndices, FieldCapsList, getCommonFields } from './helpers';
import type { SerializedEnrichPolicy } from '../../../../common';
const validationSchema = schema.object({
policy: schema.object({
name: schema.string(),
type: schema.oneOf([
schema.literal('match'),
schema.literal('range'),
schema.literal('geo_match'),
]),
matchField: schema.string(),
enrichFields: schema.arrayOf(schema.string()),
sourceIndices: schema.arrayOf(schema.string()),
query: schema.maybe(schema.any()),
}),
});
const querySchema = schema.object({
executePolicyAfterCreation: schema.maybe(
schema.oneOf([schema.literal('true'), schema.literal('false')])
),
});
const getMatchingIndicesSchema = schema.object({ pattern: schema.string() }, { unknowns: 'allow' });
const getFieldsFromIndicesSchema = schema.object({
indices: schema.arrayOf(schema.string()),
});
export function registerCreateRoute({ router, lib: { handleEsError } }: RouteDependencies) {
router.post(
{
path: addInternalBasePath('/enrich_policies'),
validate: { body: validationSchema, query: querySchema },
},
async (context, request, response) => {
const client = (await context.core).elasticsearch.client as IScopedClusterClient;
const executeAfter = Boolean(
(request.query as TypeOf<typeof querySchema>)?.executePolicyAfterCreation
);
const { policy } = request.body;
const serializedPolicy = serializeAsESPolicy(policy as SerializedEnrichPolicy);
try {
const res = await enrichPoliciesActions.create(client, policy.name, serializedPolicy);
if (executeAfter) {
try {
await enrichPoliciesActions.execute(client, policy.name);
} catch (error) {
// If executing the policy fails, remove the previously created policy and
// return the error.
await enrichPoliciesActions.remove(client, policy.name);
return handleEsError({ error, response });
}
}
return response.ok({ body: res });
} catch (error) {
return handleEsError({ error, response });
}
}
);
router.post(
{
path: addInternalBasePath('/enrich_policies/get_matching_indices'),
validate: { body: getMatchingIndicesSchema },
},
async (context, request, response) => {
let { pattern } = request.body;
const client = (await context.core).elasticsearch.client as IScopedClusterClient;
// Add wildcards to the search query to match the behavior of the
// index pattern search in the Kibana UI.
if (!pattern.startsWith('*')) {
pattern = `*${pattern}`;
}
if (!pattern.endsWith('*')) {
pattern = `${pattern}*`;
}
try {
const indices = await getIndices(client, pattern);
return response.ok({ body: { indices } });
} catch (error) {
return handleEsError({ error, response });
}
}
);
router.post(
{
path: addInternalBasePath('/enrich_policies/get_fields_from_indices'),
validate: { body: getFieldsFromIndicesSchema },
},
async (context, request, response) => {
const { indices } = request.body;
const client = (await context.core).elasticsearch.client as IScopedClusterClient;
try {
const fieldsPerIndex = await Promise.all(
indices.map((index) =>
client.asCurrentUser.fieldCaps(
{
index,
fields: ['*'],
allow_no_indices: true,
ignore_unavailable: true,
filters: '-metadata',
},
{ ignore: [404], meta: true }
)
)
);
const serializedFieldsPerIndex = indices.map((indexName: string, mapIndex: number) => {
const fields = fieldsPerIndex[mapIndex];
const json = fields.statusCode === 404 ? { fields: [] } : fields.body;
return {
index: indexName,
fields: normalizeFieldsList(json.fields as FieldCapsList),
};
});
return response.ok({
body: {
indices: serializedFieldsPerIndex,
commonFields: getCommonFields(serializedFieldsPerIndex),
},
});
} catch (error) {
return handleEsError({ error, response });
}
}
);
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { IScopedClusterClient } from '@kbn/core/server';
import { RouteDependencies } from '../../../types';
import { addInternalBasePath } from '..';
import { enrichPoliciesActions } from '../../../lib/enrich_policies';
const paramsSchema = schema.object({
name: schema.string(),
});
export function registerDeleteRoute({ router, lib: { handleEsError } }: RouteDependencies) {
router.delete(
{
path: addInternalBasePath('/enrich_policies/{name}'),
validate: { params: paramsSchema },
},
async (context, request, response) => {
const { name } = request.params;
const client = (await context.core).elasticsearch.client as IScopedClusterClient;
try {
const res = await enrichPoliciesActions.remove(client, name);
return response.ok({ body: res });
} catch (error) {
return handleEsError({ error, response });
}
}
);
}

View file

@ -8,7 +8,13 @@
import { RouteDependencies } from '../../../types';
import { registerListRoute } from './register_list_route';
import { registerDeleteRoute } from './register_delete_route';
import { registerExecuteRoute } from './register_execute_route';
import { registerCreateRoute } from './register_create_route';
export function registerEnrichPoliciesRoute(dependencies: RouteDependencies) {
registerListRoute(dependencies);
registerDeleteRoute(dependencies);
registerExecuteRoute(dependencies);
registerCreateRoute(dependencies);
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { IScopedClusterClient } from '@kbn/core/server';
import { RouteDependencies } from '../../../types';
import { addInternalBasePath } from '..';
import { enrichPoliciesActions } from '../../../lib/enrich_policies';
const paramsSchema = schema.object({
name: schema.string(),
});
export function registerExecuteRoute({ router, lib: { handleEsError } }: RouteDependencies) {
router.put(
{
path: addInternalBasePath('/enrich_policies/{name}'),
validate: { params: paramsSchema },
},
async (context, request, response) => {
const { name } = request.params;
const client = (await context.core).elasticsearch.client as IScopedClusterClient;
try {
const res = await enrichPoliciesActions.execute(client, name);
return response.ok({ body: res });
} catch (error) {
return handleEsError({ error, response });
}
}
);
}

View file

@ -34,6 +34,8 @@
"@kbn/core-http-router-server-mocks",
"@kbn/core-ui-settings-browser-mocks",
"@kbn/core-ui-settings-browser",
"@kbn/shared-ux-page-kibana-template",
"@kbn/react-field",
"@kbn/kibana-utils-plugin",
"@kbn/core-http-browser",
"@kbn/search-api-panels",

View file

@ -0,0 +1,132 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from 'expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
const INTERNAL_API_BASE_PATH = '/internal/index_management';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const es = getService('es');
const log = getService('log');
describe('Create enrich policy', function () {
const INDEX_A_NAME = `index-${Math.random()}`;
const INDEX_B_NAME = `index-${Math.random()}`;
const POLICY_NAME = `policy-${Math.random()}`;
before(async () => {
try {
await es.indices.create({
index: INDEX_A_NAME,
body: {
mappings: {
properties: {
email: {
type: 'text',
},
firstName: {
type: 'text',
},
},
},
},
});
await es.indices.create({
index: INDEX_B_NAME,
body: {
mappings: {
properties: {
email: {
type: 'text',
},
age: {
type: 'long',
},
},
},
},
});
} catch (err) {
log.debug('[Setup error] Error creating test index');
throw err;
}
});
after(async () => {
try {
await es.indices.delete({ index: INDEX_A_NAME });
await es.indices.delete({ index: INDEX_B_NAME });
} catch (err) {
log.debug('[Cleanup error] Error deleting test index');
throw err;
}
});
it('Allows to create an enrich policy', async () => {
const { body } = await supertest
.post(`${INTERNAL_API_BASE_PATH}/enrich_policies`)
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'xxx')
.send({
policy: {
name: POLICY_NAME,
type: 'match',
matchField: 'email',
enrichFields: ['firstName'],
sourceIndices: [INDEX_A_NAME],
},
})
.expect(200);
expect(body).toStrictEqual({ acknowledged: true });
});
it('Can retrieve fields from indices', async () => {
const { body } = await supertest
.post(`${INTERNAL_API_BASE_PATH}/enrich_policies/get_fields_from_indices`)
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'xxx')
.send({ indices: [INDEX_A_NAME, INDEX_B_NAME] })
.expect(200);
expect(body).toStrictEqual({
commonFields: [{ name: 'email', type: 'text', normalizedType: 'text' }],
indices: [
{
index: INDEX_A_NAME,
fields: [
{ name: 'email', type: 'text', normalizedType: 'text' },
{ name: 'firstName', type: 'text', normalizedType: 'text' },
],
},
{
index: INDEX_B_NAME,
fields: [
{ name: 'age', type: 'long', normalizedType: 'number' },
{ name: 'email', type: 'text', normalizedType: 'text' },
],
},
],
});
});
it('Can retrieve matching indices', async () => {
const { body } = await supertest
.post(`${INTERNAL_API_BASE_PATH}/enrich_policies/get_matching_indices`)
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'xxx')
.send({ pattern: 'index-' })
.expect(200);
expect(
body.indices.every((value: string) => [INDEX_A_NAME, INDEX_B_NAME].includes(value))
).toBe(true);
});
});
}

View file

@ -16,5 +16,6 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./component_templates'));
loadTestFile(require.resolve('./cluster_nodes'));
loadTestFile(require.resolve('./index_details'));
loadTestFile(require.resolve('./create_enrich_policy'));
});
}

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const pageObjects = getPageObjects(['common', 'indexManagement', 'header']);
const log = getService('log');
const security = getService('security');
const comboBox = getService('comboBox');
const testSubjects = getService('testSubjects');
const es = getService('es');
const INDEX_NAME = `index-${Math.random()}`;
const POLICY_NAME = `policy-${Math.random()}`;
describe('Create enrich policy', function () {
before(async () => {
await log.debug('Creating test index');
try {
await es.indices.create({
index: INDEX_NAME,
body: {
mappings: {
properties: {
email: {
type: 'text',
},
age: {
type: 'long',
},
},
},
},
});
} catch (e) {
log.debug('[Setup error] Error creating test policy');
throw e;
}
await log.debug('Navigating to the enrich policies tab');
await security.testUser.setRoles(['index_management_user']);
await pageObjects.common.navigateToApp('indexManagement');
// Navigate to the enrich policies tab
await pageObjects.indexManagement.changeTabs('enrich_policiesTab');
await pageObjects.header.waitUntilLoadingHasFinished();
// Click create policy button
await testSubjects.click('enrichPoliciesEmptyPromptCreateButton');
});
after(async () => {
await log.debug('Cleaning up created index');
try {
await es.indices.delete({ index: INDEX_NAME });
} catch (e) {
log.debug('[Teardown error] Error deleting test policy');
throw e;
}
});
it('shows create enrich policies page and docs link', async () => {
expect(await testSubjects.exists('createEnrichPolicyHeaderContent')).to.be(true);
expect(await testSubjects.exists('createEnrichPolicyDocumentationLink')).to.be(true);
});
it('can create an enrich policy', async () => {
// Complete configuration step
await testSubjects.setValue('policyNameField > input', POLICY_NAME);
await testSubjects.setValue('policyTypeField', 'match');
await comboBox.set('policySourceIndicesField', INDEX_NAME);
await testSubjects.click('nextButton');
// Complete field selection step
await comboBox.set('matchField', 'email');
await comboBox.set('enrichFields', 'age');
await testSubjects.click('nextButton');
// Create policy
await testSubjects.click('createButton');
// Expect to be redirected to the enrich policies tab
await pageObjects.header.waitUntilLoadingHasFinished();
// Expect to have that policy in the table
const policyList = await testSubjects.findAll('enrichPolicyDetailsLink');
expect(policyList.length).to.be(1);
});
});
};

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ loadTestFile }: FtrProviderContext) => {
describe('Index Management: create enrich policy', function () {
loadTestFile(require.resolve('./create_enrich_policy'));
});
};

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const pageObjects = getPageObjects(['common', 'indexManagement', 'header']);
const toasts = getService('toasts');
const log = getService('log');
const browser = getService('browser');
const security = getService('security');
const testSubjects = getService('testSubjects');
const es = getService('es');
const ENRICH_INDEX_NAME = 'test-policy-1';
const ENRICH_POLICY_NAME = 'test-policy-1';
describe('Enrich policies tab', function () {
before(async () => {
await log.debug('Creating required index and enrich policy');
try {
await es.indices.create({
index: ENRICH_INDEX_NAME,
body: {
mappings: {
properties: {
name: {
type: 'text',
},
},
},
},
});
await es.enrich.putPolicy({
name: ENRICH_POLICY_NAME,
match: {
indices: ENRICH_INDEX_NAME,
match_field: 'name',
enrich_fields: ['name'],
},
});
} catch (e) {
log.debug('[Setup error] Error creating test policy');
throw e;
}
await log.debug('Navigating to the enrich policies tab');
await security.testUser.setRoles(['index_management_user']);
await pageObjects.common.navigateToApp('indexManagement');
// Navigate to the enrich policies tab
await pageObjects.indexManagement.changeTabs('enrich_policiesTab');
await pageObjects.header.waitUntilLoadingHasFinished();
});
after(async () => {
await log.debug('Cleaning up created index and policy');
try {
await es.indices.delete({ index: ENRICH_INDEX_NAME });
} catch (e) {
log.debug('[Teardown error] Error deleting test policy');
throw e;
}
});
it('shows enrich policies page and docs link', async () => {
expect(await testSubjects.exists('enrichPoliciesList')).to.be(true);
expect(await testSubjects.exists('enrichPoliciesLearnMoreLink')).to.be(true);
});
it('shows the details flyout when clicking on a policy name', async () => {
// Open details flyout
await pageObjects.indexManagement.clickEnrichPolicyAt(0);
// Verify url is stateful
const url = await browser.getCurrentUrl();
expect(url).to.contain(`/enrich_policies?policy=${ENRICH_POLICY_NAME}`);
// Assert that flyout is opened
expect(await testSubjects.exists('policyDetailsFlyout')).to.be(true);
// Close flyout
await testSubjects.click('closeFlyoutButton');
});
it('can execute a policy', async () => {
await pageObjects.indexManagement.clickExecuteEnrichPolicyAt(0);
await pageObjects.indexManagement.clickConfirmModalButton();
const successToast = await toasts.getToastElement(1);
expect(await successToast.getVisibleText()).to.contain(`Executed ${ENRICH_POLICY_NAME}`);
});
it('can delete a policy', async () => {
await pageObjects.indexManagement.clickDeleteEnrichPolicyAt(0);
await pageObjects.indexManagement.clickConfirmModalButton();
const successToast = await toasts.getToastElement(2);
expect(await successToast.getVisibleText()).to.contain(`Deleted ${ENRICH_POLICY_NAME}`);
});
});
};

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ loadTestFile }: FtrProviderContext) => {
describe('Index Management: enrich policies tab', function () {
loadTestFile(require.resolve('./enrich_policies_tab'));
});
};

View file

@ -92,5 +92,22 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
expect(componentTemplateList).to.be(true);
});
});
describe('Enrich policies', () => {
it('renders the enrich policies tab', async () => {
// Navigate to the component templates tab
await pageObjects.indexManagement.changeTabs('enrich_policiesTab');
await pageObjects.header.waitUntilLoadingHasFinished();
// Verify url
const url = await browser.getCurrentUrl();
expect(url).to.contain(`/enrich_policies`);
// Verify content
const enrichPoliciesList = await testSubjects.exists('sectionEmpty');
expect(enrichPoliciesList).to.be(true);
});
});
});
};

View file

@ -12,5 +12,7 @@ export default ({ loadTestFile }: FtrProviderContext) => {
loadTestFile(require.resolve('./feature_controls'));
loadTestFile(require.resolve('./home_page'));
loadTestFile(require.resolve('./index_template_wizard'));
loadTestFile(require.resolve('./enrich_policies_tab'));
loadTestFile(require.resolve('./create_enrich_policy'));
});
};

View file

@ -559,7 +559,7 @@ export default async function ({ readConfigFile }) {
index_management_user: {
elasticsearch: {
cluster: ['monitor', 'manage_index_templates'],
cluster: ['monitor', 'manage_index_templates', 'manage_enrich'],
indices: [
{
names: ['*'],

View file

@ -30,6 +30,25 @@ export function IndexManagementPageProvider({ getService }: FtrProviderContext)
await testSubjects.click('indexTableIncludeHiddenIndicesToggle');
},
async clickEnrichPolicyAt(indexOfRow: number): Promise<void> {
const policyDetailsLinks = await testSubjects.findAll('enrichPolicyDetailsLink');
await policyDetailsLinks[indexOfRow].click();
},
async clickDeleteEnrichPolicyAt(indexOfRow: number): Promise<void> {
const deleteButons = await testSubjects.findAll('deletePolicyButton');
await deleteButons[indexOfRow].click();
},
async clickExecuteEnrichPolicyAt(indexOfRow: number): Promise<void> {
const executeButtons = await testSubjects.findAll('executePolicyButton');
await executeButtons[indexOfRow].click();
},
async clickConfirmModalButton(): Promise<void> {
await testSubjects.click('confirmModalConfirmButton');
},
async clickDetailPanelTabAt(indexOfTab: number): Promise<void> {
const tabList = await testSubjects.findAll('detailPanelTab');
log.debug(tabList.length);
@ -87,7 +106,12 @@ export function IndexManagementPageProvider({ getService }: FtrProviderContext)
},
async changeTabs(
tab: 'indicesTab' | 'data_streamsTab' | 'templatesTab' | 'component_templatesTab'
tab:
| 'indicesTab'
| 'data_streamsTab'
| 'templatesTab'
| 'component_templatesTab'
| 'enrich_policiesTab'
) {
await testSubjects.click(tab);
},

View file

@ -0,0 +1,132 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from 'expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
const INTERNAL_API_BASE_PATH = '/internal/index_management';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const es = getService('es');
const log = getService('log');
describe('Create enrich policy', function () {
const INDEX_A_NAME = `index-${Math.random()}`;
const INDEX_B_NAME = `index-${Math.random()}`;
const POLICY_NAME = `policy-${Math.random()}`;
before(async () => {
try {
await es.indices.create({
index: INDEX_A_NAME,
body: {
mappings: {
properties: {
email: {
type: 'text',
},
firstName: {
type: 'text',
},
},
},
},
});
await es.indices.create({
index: INDEX_B_NAME,
body: {
mappings: {
properties: {
email: {
type: 'text',
},
age: {
type: 'long',
},
},
},
},
});
} catch (err) {
log.debug('[Setup error] Error creating test index');
throw err;
}
});
after(async () => {
try {
await es.indices.delete({ index: INDEX_A_NAME });
await es.indices.delete({ index: INDEX_B_NAME });
} catch (err) {
log.debug('[Cleanup error] Error deleting test index');
throw err;
}
});
it('Allows to create an enrich policy', async () => {
const { body } = await supertest
.post(`${INTERNAL_API_BASE_PATH}/enrich_policies`)
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'xxx')
.send({
policy: {
name: POLICY_NAME,
type: 'match',
matchField: 'email',
enrichFields: ['firstName'],
sourceIndices: [INDEX_A_NAME],
},
})
.expect(200);
expect(body).toStrictEqual({ acknowledged: true });
});
it('Can retrieve fields from indices', async () => {
const { body } = await supertest
.post(`${INTERNAL_API_BASE_PATH}/enrich_policies/get_fields_from_indices`)
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'xxx')
.send({ indices: [INDEX_A_NAME, INDEX_B_NAME] })
.expect(200);
expect(body).toStrictEqual({
commonFields: [{ name: 'email', type: 'text', normalizedType: 'text' }],
indices: [
{
index: INDEX_A_NAME,
fields: [
{ name: 'email', type: 'text', normalizedType: 'text' },
{ name: 'firstName', type: 'text', normalizedType: 'text' },
],
},
{
index: INDEX_B_NAME,
fields: [
{ name: 'age', type: 'long', normalizedType: 'number' },
{ name: 'email', type: 'text', normalizedType: 'text' },
],
},
],
});
});
it('Can retrieve matching indices', async () => {
const { body } = await supertest
.post(`${INTERNAL_API_BASE_PATH}/enrich_policies/get_matching_indices`)
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'xxx')
.send({ pattern: 'index-' })
.expect(200);
expect(
body.indices.every((value: string) => [INDEX_A_NAME, INDEX_B_NAME].includes(value))
).toBe(true);
});
});
}

View file

@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('Index Management APIs', function () {
loadTestFile(require.resolve('./index_templates'));
loadTestFile(require.resolve('./indices'));
loadTestFile(require.resolve('./create_enrich_policies'));
});
}

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const pageObjects = getPageObjects(['common', 'indexManagement', 'header', 'svlCommonPage']);
const log = getService('log');
const security = getService('security');
const comboBox = getService('comboBox');
const testSubjects = getService('testSubjects');
const es = getService('es');
const INDEX_NAME = `index-${Math.random()}`;
const POLICY_NAME = `policy-${Math.random()}`;
describe('Create enrich policy', function () {
before(async () => {
await log.debug('Creating test index');
try {
await es.indices.create({
index: INDEX_NAME,
body: {
mappings: {
properties: {
email: {
type: 'text',
},
age: {
type: 'long',
},
},
},
},
});
} catch (e) {
log.debug('[Setup error] Error creating test policy');
throw e;
}
await log.debug('Navigating to the enrich policies tab');
await pageObjects.svlCommonPage.login();
await security.testUser.setRoles(['index_management_user']);
await pageObjects.common.navigateToApp('indexManagement');
// Navigate to the enrich policies tab
await pageObjects.indexManagement.changeTabs('enrich_policiesTab');
await pageObjects.header.waitUntilLoadingHasFinished();
// Click create policy button
await testSubjects.click('enrichPoliciesEmptyPromptCreateButton');
});
after(async () => {
await log.debug('Cleaning up created index');
try {
await es.indices.delete({ index: INDEX_NAME });
} catch (e) {
log.debug('[Teardown error] Error deleting test policy');
throw e;
} finally {
await pageObjects.svlCommonPage.forceLogout();
}
});
it('shows create enrich policies page and docs link', async () => {
expect(await testSubjects.exists('createEnrichPolicyHeaderContent')).to.be(true);
expect(await testSubjects.exists('createEnrichPolicyDocumentationLink')).to.be(true);
});
it('can create an enrich policy', async () => {
// Complete configuration step
await testSubjects.setValue('policyNameField > input', POLICY_NAME);
await testSubjects.setValue('policyTypeField', 'match');
await comboBox.set('policySourceIndicesField', INDEX_NAME);
await testSubjects.click('nextButton');
// Complete field selection step
await comboBox.set('matchField', 'email');
await comboBox.set('enrichFields', 'age');
await testSubjects.click('nextButton');
// Create policy
await testSubjects.click('createButton');
// Expect to be redirected to the enrich policies tab
await pageObjects.header.waitUntilLoadingHasFinished();
// Expect to have that policy in the table
const policyList = await testSubjects.findAll('enrichPolicyDetailsLink');
expect(policyList.length).to.be(1);
});
});
};

View file

@ -11,5 +11,6 @@ export default ({ loadTestFile }: FtrProviderContext) => {
describe('Index Management', function () {
loadTestFile(require.resolve('./index_templates'));
loadTestFile(require.resolve('./indices'));
loadTestFile(require.resolve('./create_enrich_policy'));
});
};