[Enterprise Search] Add UI to manage index and default pipelines (#140767)

This commit is contained in:
Sander Philipse 2022-09-15 21:15:02 +02:00 committed by GitHub
parent 22e78f9bb9
commit d0a0a8f889
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1943 additions and 53 deletions

View file

@ -7,6 +7,8 @@
import { i18n } from '@kbn/i18n';
import { IngestPipelineParams } from './types/connectors';
export const ENTERPRISE_SEARCH_OVERVIEW_PLUGIN = {
ID: 'enterpriseSearch',
NAME: i18n.translate('xpack.enterpriseSearch.overview.productName', {
@ -118,3 +120,11 @@ export const WORKPLACE_SEARCH_URL = '/app/enterprise_search/workplace_search';
export const ENTERPRISE_SEARCH_DOCUMENTS_DEFAULT_DOC_COUNT = 25;
export const ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE = 'elastic-crawler';
export const DEFAULT_PIPELINE_NAME = 'ent-search-generic-ingestion';
export const DEFAULT_PIPELINE_VALUES: IngestPipelineParams = {
extract_binary_content: true,
name: DEFAULT_PIPELINE_NAME,
reduce_whitespace: true,
run_ml_inference: false,
};

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mockHttpValues } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { getDefaultPipeline } from './get_default_pipeline_api_logic';
describe('getDefaultPipelineApiLogic', () => {
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
});
describe('updatePipeline', () => {
it('calls correct api', async () => {
const promise = Promise.resolve('result');
http.get.mockReturnValue(promise);
const result = getDefaultPipeline();
await nextTick();
expect(http.get).toHaveBeenCalledWith(
'/internal/enterprise_search/connectors/default_pipeline'
);
await expect(result).resolves.toEqual('result');
});
});
});

View file

@ -0,0 +1,24 @@
/*
* 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 { IngestPipelineParams } from '../../../../../common/types/connectors';
import { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export type FetchDefaultPipelineResponse = IngestPipelineParams;
export const getDefaultPipeline = async (): Promise<FetchDefaultPipelineResponse> => {
const route = '/internal/enterprise_search/connectors/default_pipeline';
return await HttpLogic.values.http.get(route);
};
export const FetchDefaultPipelineApiLogic = createApiLogic(
['content', 'get_default_pipeline_api_logic'],
getDefaultPipeline
);

View file

@ -0,0 +1,40 @@
/*
* 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 { mockHttpValues } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { updateDefaultPipeline } from './update_default_pipeline_api_logic';
describe('updateDefaultPipelineApiLogic', () => {
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
});
describe('updateDefaultPipeline', () => {
it('calls correct api', async () => {
const promise = Promise.resolve('result');
http.post.mockReturnValue(promise);
const pipeline = {
extract_binary_content: true,
name: 'pipelineName',
reduce_whitespace: false,
run_ml_inference: true,
};
const result = updateDefaultPipeline(pipeline);
await nextTick();
expect(http.put).toHaveBeenCalledWith(
'/internal/enterprise_search/connectors/default_pipeline',
{
body: JSON.stringify(pipeline),
}
);
await expect(result).resolves.toEqual(pipeline);
});
});
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IngestPipelineParams } from '../../../../../common/types/connectors';
import { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export type PostDefaultPipelineResponse = IngestPipelineParams;
export type PostDefaultPipelineArgs = IngestPipelineParams;
export const updateDefaultPipeline = async (
pipeline: IngestPipelineParams
): Promise<PostDefaultPipelineResponse> => {
const route = '/internal/enterprise_search/connectors/default_pipeline';
await HttpLogic.values.http.put(route, { body: JSON.stringify(pipeline) });
return pipeline;
};
export const UpdateDefaultPipelineApiLogic = createApiLogic(
['content', 'update_default_pipeline_api_logic'],
updateDefaultPipeline
);

View file

@ -0,0 +1,40 @@
/*
* 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 { mockHttpValues } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { updatePipeline } from './update_pipeline_api_logic';
describe('updatePipelineApiLogic', () => {
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
});
describe('updatePipeline', () => {
it('calls correct api', async () => {
const promise = Promise.resolve('result');
http.post.mockReturnValue(promise);
const pipeline = {
extract_binary_content: true,
name: 'pipelineName',
reduce_whitespace: false,
run_ml_inference: true,
};
const result = updatePipeline({ connectorId: 'connector_id', pipeline });
await nextTick();
expect(http.put).toHaveBeenCalledWith(
'/internal/enterprise_search/connectors/connector_id/pipeline',
{
body: JSON.stringify(pipeline),
}
);
await expect(result).resolves.toEqual({ connectorId: 'connector_id', pipeline });
});
});
});

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 { IngestPipelineParams } from '../../../../../common/types/connectors';
import { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface PostPipelineArgs {
connectorId: string;
pipeline: IngestPipelineParams;
}
export interface PostPipelineResponse {
connectorId: string;
pipeline: IngestPipelineParams;
}
export const updatePipeline = async ({
connectorId,
pipeline,
}: PostPipelineArgs): Promise<PostPipelineResponse> => {
const route = `/internal/enterprise_search/connectors/${connectorId}/pipeline`;
await HttpLogic.values.http.put(route, {
body: JSON.stringify(pipeline),
});
return { connectorId, pipeline };
};
export const UpdatePipelineApiLogic = createApiLogic(
['content', 'update_pipeline_api_logic'],
updatePipeline
);

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface CreateCustomPipelineApiLogicArgs {
indexName: string;
}
export interface CreateCustomPipelineApiLogicResponse {
created: string[];
}
export const createCustomPipeline = async ({
indexName,
}: CreateCustomPipelineApiLogicArgs): Promise<CreateCustomPipelineApiLogicResponse> => {
const route = `/internal/enterprise_search/indices/${indexName}/pipelines`;
const result = await HttpLogic.values.http.post<CreateCustomPipelineApiLogicResponse>(route);
return result;
};
export const CreateCustomPipelineApiLogic = createApiLogic(
['content', 'create_custom_pipeline_api_logic'],
createCustomPipeline
);

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mockHttpValues } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { fetchCustomPipeline } from './fetch_custom_pipeline_api_logic';
describe('updatePipelineApiLogic', () => {
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
});
describe('updatePipeline', () => {
it('calls correct api', async () => {
const promise = Promise.resolve('result');
http.get.mockReturnValue(promise);
const result = fetchCustomPipeline({ indexName: 'index_20' });
await nextTick();
expect(http.get).toHaveBeenCalledWith(
'/internal/enterprise_search/indices/index_20/pipelines'
);
await expect(result).resolves.toEqual('result');
});
});
});

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IngestPipeline } from '@elastic/elasticsearch/lib/api/types';
import { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface FetchCustomPipelineApiLogicArgs {
indexName: string;
}
export type FetchCustomPipelineApiLogicResponse = Record<string, IngestPipeline | undefined>;
export const fetchCustomPipeline = async ({
indexName,
}: FetchCustomPipelineApiLogicArgs): Promise<FetchCustomPipelineApiLogicResponse> => {
const route = `/internal/enterprise_search/indices/${indexName}/pipelines`;
const result = await HttpLogic.values.http.get<FetchCustomPipelineApiLogicResponse>(route);
return result;
};
export const FetchCustomPipelineApiLogic = createApiLogic(
['content', 'fetch_custom_pipeline_api_logic'],
fetchCustomPipeline
);

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCodeBlock } from '@elastic/eui';
import { IngestPipelineParams } from '../../../../../../../common/types/connectors';
import { useCloudDetails } from '../../../../../shared/cloud_details/cloud_details';
import { decodeCloudId } from '../../../../utils/decode_cloud_id';
interface CurlRequestParams {
apiKey?: string;
document?: Record<string, unknown>;
indexName: string;
pipeline?: IngestPipelineParams;
}
export const CurlRequest: React.FC<CurlRequestParams> = ({
indexName,
apiKey,
document,
pipeline,
}) => {
const cloudContext = useCloudDetails();
const DEFAULT_URL = 'https://localhost:9200';
const baseUrl =
(cloudContext.cloudId && decodeCloudId(cloudContext.cloudId)?.elasticsearchUrl) || DEFAULT_URL;
const apiKeyExample = apiKey || '<Replace_with_created_API_key>';
const { name: pipelineName, ...pipelineParams } = pipeline ?? {};
// We have to prefix the parameters with an underscore because that's what the actual pipeline looks for
const pipelineArgs = Object.entries(pipelineParams).reduce(
(acc: Record<string, boolean | undefined>, curr) => ({ ...acc, [`_${curr[0]}`]: curr[1] }),
{}
);
const inputDocument = pipeline ? { ...document, ...pipelineArgs } : document;
return (
<EuiCodeBlock language="bash" fontSize="m" isCopyable>
{`\
curl -X POST '${baseUrl}/${indexName}/_doc${pipeline ? `?pipeline=${pipelineName}` : ''}' \\
-H 'Content-Type: application/json' \\
-H 'Authorization: ApiKey ${apiKeyExample}' \\
-d '${JSON.stringify(inputDocument, null, 2)}'
`}
</EuiCodeBlock>
);
};

View file

@ -5,48 +5,38 @@
* 2.0.
*/
import React from 'react';
import React, { useState } from 'react';
import { useActions, useValues } from 'kea';
import {
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiSwitch, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useCloudDetails } from '../../../shared/cloud_details/cloud_details';
import { decodeCloudId } from '../../utils/decode_cloud_id';
import { DOCUMENTS_API_JSON_EXAMPLE } from '../new_index/constants';
import { SettingsLogic } from '../settings/settings_logic';
import { ClientLibrariesPopover } from './components/client_libraries_popover/popover';
import { CurlRequest } from './components/curl_request/curl_request';
import { GenerateApiKeyModal } from './components/generate_api_key_modal/modal';
import { ManageKeysPopover } from './components/manage_api_keys_popover/popover';
import { IndexViewLogic } from './index_view_logic';
import { OverviewLogic } from './overview.logic';
export const GenerateApiKeyPanel: React.FC = () => {
const { apiKey, isGenerateModalOpen, indexData } = useValues(OverviewLogic);
const { apiKey, isGenerateModalOpen } = useValues(OverviewLogic);
const { indexName } = useValues(IndexViewLogic);
const { closeGenerateModal } = useActions(OverviewLogic);
const { defaultPipeline } = useValues(SettingsLogic);
const cloudContext = useCloudDetails();
const DEFAULT_URL = 'https://localhost:9200';
const searchIndexApiUrl =
(cloudContext.cloudId && decodeCloudId(cloudContext.cloudId)?.elasticsearchUrl) || DEFAULT_URL;
const apiKeyExample = apiKey || '<Create an API Key>';
const [optimizedRequest, setOptimizedRequest] = useState(true);
return (
<>
{isGenerateModalOpen && (
<GenerateApiKeyModal indexName={indexData?.name ?? ''} onClose={closeGenerateModal} />
<GenerateApiKeyModal indexName={indexName} onClose={closeGenerateModal} />
)}
<EuiFlexGroup>
<EuiFlexItem>
@ -55,7 +45,7 @@ export const GenerateApiKeyPanel: React.FC = () => {
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
{indexData?.name[0] !== '.' && (
{indexName[0] !== '.' && (
<EuiTitle size="s">
<h2>
{i18n.translate(
@ -66,6 +56,16 @@ export const GenerateApiKeyPanel: React.FC = () => {
</EuiTitle>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSwitch
onChange={(event) => setOptimizedRequest(event.target.checked)}
label={i18n.translate(
'xpack.enterpriseSearch.content.overview.optimizedRequest.label',
{ defaultMessage: 'View Enterprise Search optimized request' }
)}
checked={optimizedRequest}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
<EuiFlexItem>
@ -78,18 +78,16 @@ export const GenerateApiKeyPanel: React.FC = () => {
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{indexData?.name[0] !== '.' && (
{indexName[0] !== '.' && (
<>
<EuiSpacer />
<EuiFlexItem>
<EuiCodeBlock language="bash" fontSize="m" isCopyable>
{`\
curl -X POST '${searchIndexApiUrl}/${indexData?.name}/_doc' \\
-H 'Content-Type: application/json' \\
-H 'Authorization: ApiKey ${apiKeyExample}' \\
-d '${JSON.stringify(DOCUMENTS_API_JSON_EXAMPLE, null, 2)}'
`}
</EuiCodeBlock>
<CurlRequest
apiKey={apiKey}
document={DOCUMENTS_API_JSON_EXAMPLE}
indexName={indexName}
pipeline={optimizedRequest ? defaultPipeline : undefined}
/>
</EuiFlexItem>
</>
)}

View file

@ -9,7 +9,11 @@ import { kea, MakeLogicType } from 'kea';
import { i18n } from '@kbn/i18n';
import { SyncStatus } from '../../../../../common/types/connectors';
import {
Connector,
IngestPipelineParams,
SyncStatus,
} from '../../../../../common/types/connectors';
import { Actions } from '../../../shared/api_logic/create_api_logic';
import {
flashAPIErrors,
@ -62,6 +66,7 @@ export interface IndexViewActions {
}
export interface IndexViewValues {
connector: Connector | undefined;
connectorId: string | null;
data: typeof FetchIndexApiLogic.values.data;
fetchIndexTimeoutId: NodeJS.Timeout | null;
@ -73,6 +78,7 @@ export interface IndexViewValues {
isWaitingForSync: boolean;
lastUpdated: string | null;
localSyncNowValue: boolean; // holds local value after update so UI updates correctly
pipelineData: IngestPipelineParams | undefined;
recheckIndexLoading: boolean;
resetFetchIndexLoading: boolean;
syncStatus: SyncStatus | null;
@ -217,6 +223,13 @@ export const IndexViewLogic = kea<MakeLogicType<IndexViewValues, IndexViewAction
],
},
selectors: ({ selectors }) => ({
connector: [
() => [selectors.index],
(index: ElasticsearchViewIndex | undefined) =>
index && (isConnectorViewIndex(index) || isCrawlerIndex(index))
? index.connector
: undefined,
],
connectorId: [
() => [selectors.index],
(index) => (isConnectorViewIndex(index) ? index.connector.id : null),
@ -233,6 +246,10 @@ export const IndexViewLogic = kea<MakeLogicType<IndexViewValues, IndexViewAction
(data, localSyncNowValue) => data?.connector?.sync_now || localSyncNowValue,
],
lastUpdated: [() => [selectors.data], (data) => getLastUpdated(data)],
pipelineData: [
() => [selectors.connector],
(connector: Connector | undefined) => connector?.pipeline ?? undefined,
],
syncStatus: [() => [selectors.data], (data) => data?.connector?.last_sync_status ?? null],
}),
});

View file

@ -0,0 +1,71 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiText, EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers';
export const CustomPipelinePanel: React.FC<{
indexName: string;
pipelineSuffix: string;
processorsCount: number;
}> = ({ indexName, pipelineSuffix, processorsCount }) => {
return (
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiTitle size="xs">
<h4>{`${indexName}@${pipelineSuffix}`}</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmptyTo
to={`/app/management/ingest/ingest_pipelines/?pipeline=${indexName}@${pipelineSuffix}`}
shouldNotCreateHref
>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.customButtonLabel',
{ defaultMessage: 'Edit pipeline' }
)}
</EuiButtonEmptyTo>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiText size="s" color="subdued" grow={false}>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.customDescription',
{
defaultMessage: 'Custom ingest pipeline for {indexName}',
values: { indexName },
}
)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.processorsDescription',
{
defaultMessage: '{processorsCount} Processors',
values: { processorsCount },
}
)}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,235 @@
/*
* 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,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiLink,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DEFAULT_PIPELINE_NAME } from '../../../../../../common/constants';
import { IngestPipelineParams } from '../../../../../../common/types/connectors';
import { CurlRequest } from '../components/curl_request/curl_request';
import { PipelineSettingsForm } from './pipeline_settings_form';
interface IngestPipelineModalProps {
closeModal: () => void;
createCustomPipelines: () => void;
displayOnly: boolean;
indexName: string;
isGated: boolean;
isLoading: boolean;
pipeline: IngestPipelineParams;
savePipeline: () => void;
setPipeline: (pipeline: IngestPipelineParams) => void;
showModal: boolean;
}
export const IngestPipelineModal: React.FC<IngestPipelineModalProps> = ({
closeModal,
createCustomPipelines,
displayOnly,
indexName,
isGated,
isLoading,
pipeline,
savePipeline,
setPipeline,
showModal,
}) => {
const { name } = pipeline;
// can't customize if you already have a custom pipeline!
const canCustomize = name === DEFAULT_PIPELINE_NAME;
return showModal ? (
<EuiModal onClose={closeModal}>
<EuiModalHeader>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiModalHeaderTitle>
{i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.modalHeaderTitle',
{
defaultMessage: 'Pipeline settings',
}
)}
</EuiModalHeaderTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued">
<strong>{name}</strong>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiText color="subdued" grow={false} size="s">
{displayOnly
? i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.modalBodyAPIText',
{
defaultMessage:
'This pipeline runs automatically on all Crawler and Connector indices created through Enterprise Search. To use this configuration on API-based indices you can use the sample cURL request below.',
}
)
: i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.modalBodyConnectorText',
{
defaultMessage:
'This pipeline runs automatically on all Crawler and Connector indices created through Enterprise Search.',
}
)}
</EuiText>
</EuiFlexItem>
<EuiSpacer />
<EuiFlexItem>
<EuiLink href="TODO TODO TODO: Insert actual docslink" external>
{i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.modalIngestLinkLabel',
{
defaultMessage: 'Learn more about Enterprise Search ingest pipelines',
}
)}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiSpacer size="xl" />
<EuiFlexItem>
<EuiForm aria-labelledby="ingestPipelineHeader">
<EuiFormRow>
<EuiText size="m" id="ingestPipelineHeader">
<strong>
{i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.settings.formHeader',
{
defaultMessage: 'Optimize your content for search',
}
)}
</strong>
</EuiText>
</EuiFormRow>
<EuiFormRow>
<PipelineSettingsForm pipeline={pipeline} setPipeline={setPipeline} />
</EuiFormRow>
</EuiForm>
</EuiFlexItem>
{displayOnly && (
<>
<EuiSpacer size="xl" />
<EuiFlexItem grow={false}>
<EuiText size="m" id="ingestPipelineHeader" grow={false}>
<strong>
{i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.curlHeader',
{
defaultMessage: 'Sample cURL request',
}
)}
</strong>
</EuiText>
<EuiSpacer />
<CurlRequest
document={{ body: 'body', title: 'Title' }}
indexName={indexName}
pipeline={pipeline}
/>
</EuiFlexItem>
</>
)}
{canCustomize && (
<>
<EuiSpacer />
<EuiFlexItem>
<EuiText color="subdued" size="s" grow={false}>
{i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.platinumText',
{
defaultMessage:
'With a platinum license, you can create an index-specific version of this configuration and modify it for your use case.',
}
)}
</EuiText>
</EuiFlexItem>
<EuiSpacer />
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexStart">
<EuiButtonEmpty
disabled={isGated}
iconType={isGated ? 'lock' : undefined}
onClick={createCustomPipelines}
>
{i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.copyButtonLabel',
{ defaultMessage: 'Copy and customize' }
)}
</EuiButtonEmpty>
</EuiFlexGroup>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
{displayOnly ? (
<EuiButton fill onClick={closeModal}>
{i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.closeButtonLabel',
{
defaultMessage: 'Close',
}
)}
</EuiButton>
) : (
<>
<EuiButtonEmpty onClick={closeModal}>
{i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
)}
</EuiButtonEmpty>
<EuiButton fill onClick={savePipeline} isLoading={isLoading}>
{i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.ingestModal.saveButtonLabel',
{
defaultMessage: 'Save',
}
)}
</EuiButton>
</>
)}
</EuiModalFooter>
</EuiModal>
) : (
<></>
);
};

View file

@ -0,0 +1,131 @@
/*
* 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 { useActions, useValues } from 'kea';
import {
EuiPanel,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiButtonEmpty,
EuiAccordion,
EuiBadge,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { KibanaLogic } from '../../../../shared/kibana';
import { LicensingLogic } from '../../../../shared/licensing';
import { CreateCustomPipelineApiLogic } from '../../../api/index/create_custom_pipeline_api_logic';
import { FetchCustomPipelineApiLogic } from '../../../api/index/fetch_custom_pipeline_api_logic';
import { CurlRequest } from '../components/curl_request/curl_request';
import { IndexViewLogic } from '../index_view_logic';
import { CustomPipelinePanel } from './custom_pipeline_panel';
import { IngestPipelineModal } from './ingest_pipeline_modal';
import { PipelinesLogic } from './pipelines_logic';
export const IngestPipelinesCard: React.FC = () => {
const { indexName } = useValues(IndexViewLogic);
const { canSetPipeline, pipelineState, showModal } = useValues(PipelinesLogic);
const { closeModal, openModal, setPipelineState, savePipeline } = useActions(PipelinesLogic);
const { makeRequest: fetchCustomPipeline } = useActions(FetchCustomPipelineApiLogic);
const { makeRequest: createCustomPipeline } = useActions(CreateCustomPipelineApiLogic);
const { data: customPipelines } = useValues(FetchCustomPipelineApiLogic);
const { isCloud } = useValues(KibanaLogic);
const { hasPlatinumLicense } = useValues(LicensingLogic);
const isGated = !isCloud && !hasPlatinumLicense;
const customPipeline = customPipelines ? customPipelines[`${indexName}@custom`] : undefined;
useEffect(() => {
fetchCustomPipeline({ indexName });
}, [indexName]);
return (
<EuiFlexGroup direction="column" gutterSize="s">
<IngestPipelineModal
closeModal={closeModal}
createCustomPipelines={() => createCustomPipeline({ indexName })}
displayOnly={!canSetPipeline}
indexName={indexName}
isGated={isGated}
isLoading={false}
pipeline={pipelineState}
savePipeline={savePipeline}
setPipeline={setPipelineState}
showModal={showModal}
/>
{customPipeline && (
<EuiFlexItem>
<EuiPanel color="primary">
<CustomPipelinePanel
indexName={indexName}
pipelineSuffix="custom"
processorsCount={customPipeline.processors?.length ?? 0}
/>
</EuiPanel>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiPanel color="subdued">
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiTitle size="xs">
<h4>{pipelineState.name}</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={openModal}>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.settings.label',
{ defaultMessage: 'Settings' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<EuiAccordion
buttonContent={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.accordion.label',
{ defaultMessage: 'View sample cURL request' }
)}
id="ingestPipelinesCurlAccordion"
>
<CurlRequest
document={{ body: 'body', title: 'Title' }}
indexName={indexName}
pipeline={pipelineState}
/>
</EuiAccordion>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.managedBadge.label',
{ defaultMessage: 'Managed' }
)}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { IngestPipelineParams } from '../../../../../../common/types/connectors';
import { SettingsCheckableCard } from '../../shared/settings_checkable_card/settings_checkable_card';
interface PipelineSettingsFormProps {
pipeline: IngestPipelineParams;
setPipeline: (pipeline: IngestPipelineParams) => void;
}
export const PipelineSettingsForm: React.FC<PipelineSettingsFormProps> = ({
setPipeline,
pipeline,
}) => {
const {
extract_binary_content: extractBinaryContent,
reduce_whitespace: reduceWhitespace,
run_ml_inference: runMLInference,
} = pipeline;
return (
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<SettingsCheckableCard
description={i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.settings.extractBinaryDescription',
{
defaultMessage: 'Extract content from images and PDF files',
}
)}
label={i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.settings.extractBinaryLabel',
{
defaultMessage: 'Content extraction',
}
)}
onChange={() =>
setPipeline({
...pipeline,
extract_binary_content: !pipeline.extract_binary_content,
})
}
checked={extractBinaryContent}
id="ingestPipelineExtractBinaryContent"
/>
</EuiFlexItem>
<EuiFlexItem>
<SettingsCheckableCard
id="ingestPipelineReduceWhitespace"
checked={reduceWhitespace}
description={i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.settings.reduceWhitespaceDescription',
{
defaultMessage: 'Trim extra whitespace from your documents automatically',
}
)}
label={i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.settings.reduceWhitespaceLabel',
{
defaultMessage: 'Reduce whitespace',
}
)}
onChange={() =>
setPipeline({ ...pipeline, reduce_whitespace: !pipeline.reduce_whitespace })
}
/>
</EuiFlexItem>
<EuiFlexItem>
<SettingsCheckableCard
id="ingestPipelineRunMlInference"
checked={runMLInference}
description={i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.settings.runMlInferenceDescrition',
{
defaultMessage: 'Enhance your data using compatible trained ML models',
}
)}
label={i18n.translate(
'xpack.enterpriseSearch.content.index.pipelines.settings.mlInferenceLabel',
{
defaultMessage: 'ML Inference Pipelines',
}
)}
onChange={() =>
setPipeline({ ...pipeline, run_ml_inference: !pipeline.run_ml_inference })
}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -15,6 +15,7 @@ import { InferencePipeline } from '../../../../../../common/types/pipelines';
import { DataPanel } from '../../../../shared/data_panel/data_panel';
import { InferencePipelineCard } from './inference_pipeline_card';
import { IngestPipelinesCard } from './ingest_pipelines_card';
export const SearchIndexPipelines: React.FC = () => {
// TODO: REPLACE THIS DATA WITH REAL DATA
@ -75,7 +76,7 @@ export const SearchIndexPipelines: React.FC = () => {
)}
iconType="logstashInput"
>
<div />
<IngestPipelinesCard />
</DataPanel>
</EuiFlexItem>
<EuiFlexItem>

View file

@ -0,0 +1,184 @@
/*
* 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 { LogicMounter, mockFlashMessageHelpers } from '../../../../__mocks__/kea_logic';
import { connectorIndex } from '../../../__mocks__/view_index.mock';
import { UpdatePipelineApiLogic } from '../../../api/connector/update_pipeline_api_logic';
import { FetchIndexApiLogic } from '../../../api/index/fetch_index_api_logic';
import { PipelinesLogic } from './pipelines_logic';
const DEFAULT_PIPELINE_VALUES = {
extract_binary_content: true,
name: 'ent-search-generic-ingestion',
reduce_whitespace: true,
run_ml_inference: false,
};
const DEFAULT_VALUES = {
canSetPipeline: true,
defaultPipelineValues: DEFAULT_PIPELINE_VALUES,
defaultPipelineValuesData: undefined,
index: undefined,
pipelineState: DEFAULT_PIPELINE_VALUES,
showModal: false,
};
describe('PipelinesLogic', () => {
const { mount } = new LogicMounter(PipelinesLogic);
const { mount: mountFetchIndexApiLogic } = new LogicMounter(FetchIndexApiLogic);
const { mount: mountUpdatePipelineLogic } = new LogicMounter(UpdatePipelineApiLogic);
const { clearFlashMessages, flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers;
const newPipeline = {
...DEFAULT_PIPELINE_VALUES,
name: 'new_pipeline_name',
run_ml_inference: true,
};
beforeEach(() => {
jest.clearAllMocks();
mountFetchIndexApiLogic();
mountUpdatePipelineLogic();
mount();
});
it('has expected default values', () => {
expect(PipelinesLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
it('should set showModal to false and call fetchApiSuccess', async () => {
FetchIndexApiLogic.actions.apiSuccess(connectorIndex);
PipelinesLogic.actions.fetchIndexApiSuccess = jest.fn();
PipelinesLogic.actions.setPipelineState(newPipeline);
PipelinesLogic.actions.openModal();
PipelinesLogic.actions.apiSuccess({ connectorId: 'a', pipeline: newPipeline });
expect(PipelinesLogic.values).toEqual({
...DEFAULT_VALUES,
index: {
...connectorIndex,
connector: { ...connectorIndex.connector },
},
});
expect(flashSuccessToast).toHaveBeenCalled();
expect(PipelinesLogic.actions.fetchIndexApiSuccess).toHaveBeenCalledWith({
...connectorIndex,
connector: {
...connectorIndex.connector,
pipeline: newPipeline,
},
});
});
it('should set pipelineState on setPipeline', () => {
PipelinesLogic.actions.setPipelineState({
...DEFAULT_PIPELINE_VALUES,
name: 'new_pipeline_name',
});
expect(PipelinesLogic.values).toEqual({
...DEFAULT_VALUES,
pipelineState: { ...DEFAULT_PIPELINE_VALUES, name: 'new_pipeline_name' },
});
});
describe('makeRequest', () => {
it('should call clearFlashMessages', () => {
PipelinesLogic.actions.makeRequest({ connectorId: 'a', pipeline: DEFAULT_PIPELINE_VALUES });
expect(clearFlashMessages).toHaveBeenCalled();
});
});
describe('openModal', () => {
it('should set showModal to true', () => {
PipelinesLogic.actions.openModal();
expect(PipelinesLogic.values).toEqual({ ...DEFAULT_VALUES, showModal: true });
});
});
describe('closeModal', () => {
it('should set showModal to false', () => {
PipelinesLogic.actions.openModal();
PipelinesLogic.actions.closeModal();
expect(PipelinesLogic.values).toEqual({ ...DEFAULT_VALUES, showModal: false });
});
});
describe('apiError', () => {
it('should call flashAPIError', () => {
PipelinesLogic.actions.apiError('error' as any);
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
});
describe('apiSuccess', () => {
it('should call flashSuccessToast', () => {
PipelinesLogic.actions.apiSuccess({ connectorId: 'a', pipeline: newPipeline });
expect(flashSuccessToast).toHaveBeenCalledWith('Pipelines successfully updated');
});
});
describe('createCustomPipelineError', () => {
it('should call flashAPIError', () => {
PipelinesLogic.actions.createCustomPipelineError('error' as any);
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
});
describe('createCustomPipelineSuccess', () => {
it('should call flashSuccessToast', () => {
PipelinesLogic.actions.setPipelineState = jest.fn();
PipelinesLogic.actions.savePipeline = jest.fn();
PipelinesLogic.actions.fetchCustomPipeline = jest.fn();
PipelinesLogic.actions.fetchIndexApiSuccess(connectorIndex);
PipelinesLogic.actions.createCustomPipelineSuccess({ created: ['a', 'b'] });
expect(flashSuccessToast).toHaveBeenCalledWith('Custom pipeline successfully created');
expect(PipelinesLogic.actions.setPipelineState).toHaveBeenCalledWith({
...PipelinesLogic.values.pipelineState,
name: 'a',
});
expect(PipelinesLogic.actions.savePipeline).toHaveBeenCalled();
expect(PipelinesLogic.actions.fetchCustomPipeline).toHaveBeenCalled();
});
});
describe('fetchIndexApiSuccess', () => {
it('should set pipelineState if not editing', () => {
PipelinesLogic.actions.fetchIndexApiSuccess({
...connectorIndex,
connector: { ...connectorIndex.connector, pipeline: newPipeline },
});
expect(PipelinesLogic.values).toEqual({
...DEFAULT_VALUES,
index: {
...connectorIndex,
connector: { ...connectorIndex.connector, pipeline: newPipeline },
},
pipelineState: newPipeline,
});
});
it('should not set configState if modal is open', () => {
PipelinesLogic.actions.openModal();
PipelinesLogic.actions.fetchIndexApiSuccess({
...connectorIndex,
connector: { ...connectorIndex.connector, pipeline: newPipeline },
});
expect(PipelinesLogic.values).toEqual({
...DEFAULT_VALUES,
index: {
...connectorIndex,
connector: { ...connectorIndex.connector, pipeline: newPipeline },
},
showModal: true,
});
});
});
describe('savePipeline', () => {
it('should call makeRequest', () => {
PipelinesLogic.actions.makeRequest = jest.fn();
PipelinesLogic.actions.fetchIndexApiSuccess(connectorIndex);
PipelinesLogic.actions.savePipeline();
expect(PipelinesLogic.actions.makeRequest).toHaveBeenCalledWith({
connectorId: '2',
pipeline: DEFAULT_PIPELINE_VALUES,
});
});
});
});
});

View file

@ -0,0 +1,224 @@
/*
* 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 { kea, MakeLogicType } from 'kea';
import { i18n } from '@kbn/i18n';
import { DEFAULT_PIPELINE_VALUES } from '../../../../../../common/constants';
import { IngestPipelineParams } from '../../../../../../common/types/connectors';
import { ElasticsearchIndexWithIngestion } from '../../../../../../common/types/indices';
import { Actions } from '../../../../shared/api_logic/create_api_logic';
import {
clearFlashMessages,
flashAPIErrors,
flashSuccessToast,
} from '../../../../shared/flash_messages';
import {
FetchDefaultPipelineApiLogic,
FetchDefaultPipelineResponse,
} from '../../../api/connector/get_default_pipeline_api_logic';
import {
PostPipelineArgs,
PostPipelineResponse,
UpdatePipelineApiLogic,
} from '../../../api/connector/update_pipeline_api_logic';
import {
CreateCustomPipelineApiLogic,
CreateCustomPipelineApiLogicArgs,
CreateCustomPipelineApiLogicResponse,
} from '../../../api/index/create_custom_pipeline_api_logic';
import {
FetchCustomPipelineApiLogicArgs,
FetchCustomPipelineApiLogicResponse,
FetchCustomPipelineApiLogic,
} from '../../../api/index/fetch_custom_pipeline_api_logic';
import {
FetchIndexApiLogic,
FetchIndexApiParams,
FetchIndexApiResponse,
} from '../../../api/index/fetch_index_api_logic';
import { isApiIndex, isConnectorIndex, isCrawlerIndex } from '../../../utils/indices';
type PipelinesActions = Pick<
Actions<PostPipelineArgs, PostPipelineResponse>,
'apiError' | 'apiSuccess' | 'makeRequest'
> & {
closeModal: () => void;
createCustomPipeline: Actions<
CreateCustomPipelineApiLogicArgs,
CreateCustomPipelineApiLogicResponse
>['makeRequest'];
createCustomPipelineError: Actions<
CreateCustomPipelineApiLogicArgs,
CreateCustomPipelineApiLogicResponse
>['apiError'];
createCustomPipelineSuccess: Actions<
CreateCustomPipelineApiLogicArgs,
CreateCustomPipelineApiLogicResponse
>['apiSuccess'];
fetchCustomPipeline: Actions<
FetchCustomPipelineApiLogicArgs,
FetchCustomPipelineApiLogicResponse
>['makeRequest'];
fetchDefaultPipeline: Actions<undefined, FetchDefaultPipelineResponse>['makeRequest'];
fetchDefaultPipelineSuccess: Actions<undefined, FetchDefaultPipelineResponse>['apiSuccess'];
fetchIndexApiSuccess: Actions<FetchIndexApiParams, FetchIndexApiResponse>['apiSuccess'];
openModal: () => void;
savePipeline: () => void;
setPipelineState(pipeline: IngestPipelineParams): {
pipeline: IngestPipelineParams;
};
};
interface PipelinesValues {
canSetPipeline: boolean;
defaultPipelineValues: IngestPipelineParams;
defaultPipelineValuesData: IngestPipelineParams | null;
index: FetchIndexApiResponse;
pipelineState: IngestPipelineParams;
showModal: boolean;
}
export const PipelinesLogic = kea<MakeLogicType<PipelinesValues, PipelinesActions>>({
actions: {
closeModal: true,
openModal: true,
savePipeline: true,
setPipelineState: (pipeline: IngestPipelineParams) => ({ pipeline }),
},
connect: {
actions: [
CreateCustomPipelineApiLogic,
[
'apiError as createCustomPipelineError',
'apiSuccess as createCustomPipelineSuccess',
'makeRequest as createCustomPipeline',
],
UpdatePipelineApiLogic,
['apiSuccess', 'apiError', 'makeRequest'],
FetchIndexApiLogic,
['apiSuccess as fetchIndexApiSuccess'],
FetchDefaultPipelineApiLogic,
['apiSuccess as fetchDefaultPipelineSuccess', 'makeRequest as fetchDefaultPipeline'],
FetchCustomPipelineApiLogic,
['makeRequest as fetchCustomPipeline'],
],
values: [
FetchDefaultPipelineApiLogic,
['data as defaultPipelineValuesData'],
FetchIndexApiLogic,
['data as index'],
],
},
events: ({ actions, values }) => ({
afterMount: () => {
actions.fetchDefaultPipeline(undefined);
actions.setPipelineState(
isConnectorIndex(values.index) || isCrawlerIndex(values.index)
? values.index.connector?.pipeline ?? values.defaultPipelineValues
: values.defaultPipelineValues
);
},
}),
listeners: ({ actions, values }) => ({
apiError: (error) => flashAPIErrors(error),
apiSuccess: ({ pipeline }) => {
if (isConnectorIndex(values.index) || isCrawlerIndex(values.index)) {
if (values.index.connector) {
// had to split up these if checks rather than nest them or typescript wouldn't recognize connector as defined
actions.fetchIndexApiSuccess({
...values.index,
connector: { ...values.index.connector, pipeline },
});
}
}
flashSuccessToast(
i18n.translate('xpack.enterpriseSearch.content.indices.pipelines.successToast.title', {
defaultMessage: 'Pipelines successfully updated',
})
);
},
closeModal: () =>
actions.setPipelineState(
isConnectorIndex(values.index) || isCrawlerIndex(values.index)
? values.index.connector?.pipeline ?? values.defaultPipelineValues
: values.defaultPipelineValues
),
createCustomPipelineError: (error) => flashAPIErrors(error),
createCustomPipelineSuccess: ({ created }) => {
flashSuccessToast(
i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.successToastCustom.title',
{
defaultMessage: 'Custom pipeline successfully created',
}
)
);
actions.setPipelineState({ ...values.pipelineState, name: created[0] });
actions.savePipeline();
actions.fetchCustomPipeline({ indexName: values.index.name });
},
fetchIndexApiSuccess: (index) => {
if (!values.showModal) {
// Don't do this when the modal is open to avoid overwriting the values while editing
const pipeline =
isConnectorIndex(index) || isCrawlerIndex(index)
? index.connector?.pipeline
: values.defaultPipelineValues;
actions.setPipelineState(pipeline ?? values.defaultPipelineValues);
}
},
makeRequest: () => clearFlashMessages(),
openModal: () => {
const pipeline =
isCrawlerIndex(values.index) || isConnectorIndex(values.index)
? values.index.connector?.pipeline
: values.defaultPipelineValues;
actions.setPipelineState(pipeline ?? values.defaultPipelineValues);
},
savePipeline: () => {
if (isConnectorIndex(values.index) || isCrawlerIndex(values.index)) {
if (values.index.connector) {
actions.makeRequest({
connectorId: values.index.connector?.id,
pipeline: values.pipelineState,
});
}
}
},
}),
path: ['enterprise_search', 'content', 'pipelines'],
reducers: () => ({
pipelineState: [
DEFAULT_PIPELINE_VALUES,
{
setPipelineState: (_, { pipeline }) => pipeline,
},
],
showModal: [
false,
{
apiSuccess: () => false,
closeModal: () => false,
openModal: () => true,
},
],
}),
selectors: ({ selectors }) => ({
canSetPipeline: [
() => [selectors.index],
(index: ElasticsearchIndexWithIngestion) => !isApiIndex(index),
],
defaultPipelineValues: [
() => [selectors.defaultPipelineValuesData],
(pipeline: IngestPipelineParams | null) => pipeline ?? DEFAULT_PIPELINE_VALUES,
],
}),
});

View file

@ -14,10 +14,8 @@ import { useValues } from 'kea';
import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { Status } from '../../../../../common/types/api';
import { enableIndexPipelinesTab } from '../../../../../common/ui_settings_keys';
import { generateEncodedPath } from '../../../shared/encode_path_params';
import { KibanaLogic } from '../../../shared/kibana';
import { FetchIndexApiLogic } from '../../api/index/fetch_index_api_logic';
@ -60,14 +58,9 @@ export const SearchIndex: React.FC = () => {
const { tabId = SearchIndexTabId.OVERVIEW } = useParams<{
tabId?: string;
}>();
const {
services: { uiSettings },
} = useKibana();
const { indexName } = useValues(IndexNameLogic);
const pipelinesEnabled = uiSettings?.get<boolean>(enableIndexPipelinesTab) ?? false;
const ALL_INDICES_TABS: EuiTabbedContentTab[] = [
{
content: <SearchIndexOverview />,
@ -126,21 +119,19 @@ export const SearchIndex: React.FC = () => {
},
];
const PIPELINES_TAB: EuiTabbedContentTab[] = [
{
content: <SearchIndexPipelines />,
id: SearchIndexTabId.PIPELINES,
name: i18n.translate('xpack.enterpriseSearch.content.searchIndex.pipelinesTabLabel', {
defaultMessage: 'Pipelines',
}),
},
];
const PIPELINES_TAB: EuiTabbedContentTab = {
content: <SearchIndexPipelines />,
id: SearchIndexTabId.PIPELINES,
name: i18n.translate('xpack.enterpriseSearch.content.searchIndex.pipelinesTabLabel', {
defaultMessage: 'Pipelines',
}),
};
const tabs: EuiTabbedContentTab[] = [
...ALL_INDICES_TABS,
...(isConnectorIndex(indexData) ? CONNECTOR_TABS : []),
...(isCrawlerIndex(indexData) ? CRAWLER_TABS : []),
...(pipelinesEnabled ? PIPELINES_TAB : []),
PIPELINES_TAB,
];
const selectedTab = tabs.find((tab) => tab.id === tabId);

View file

@ -7,11 +7,26 @@
import React from 'react';
import { useActions, useValues } from 'kea';
import { EuiButton, EuiLink, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EnterpriseSearchContentPageTemplate } from '../layout/page_template';
import { SettingsLogic } from './settings_logic';
import { SettingsPanel } from './settings_panel';
export const Settings: React.FC = () => {
const { makeRequest, setPipeline } = useActions(SettingsLogic);
const { defaultPipeline, hasNoChanges, isLoading, pipelineState } = useValues(SettingsLogic);
const {
extract_binary_content: extractBinaryContent,
reduce_whitespace: reduceWhitespace,
run_ml_inference: runMLInference,
} = pipelineState;
return (
<EnterpriseSearchContentPageTemplate
pageChrome={[
@ -22,10 +37,121 @@ export const Settings: React.FC = () => {
defaultMessage: 'Settings',
}),
]}
pageHeader={{
pageTitle: i18n.translate('xpack.enterpriseSearch.content.settings.headerTitle', {
defaultMessage: 'Content Settings',
}),
rightSideItems: [
<EuiButton
fill
disabled={hasNoChanges}
isLoading={isLoading}
onClick={() => makeRequest(pipelineState)}
>
{i18n.translate('xpack.enterpriseSearch.content.settings.saveButtonLabel', {
defaultMessage: 'Save',
})}
</EuiButton>,
<EuiButton
disabled={hasNoChanges}
isLoading={isLoading}
onClick={() => setPipeline(defaultPipeline)}
>
{i18n.translate('xpack.enterpriseSearch.content.settings.resetButtonLabel', {
defaultMessage: 'Reset',
})}
</EuiButton>,
],
}}
pageViewTelemetry="Settings"
isLoading={false}
>
<>Settings</>
<SettingsPanel
description={i18n.translate(
'xpack.enterpriseSearch.content.settings.contentExtraction.description',
{
defaultMessage:
'Allow all ingestion mechanisms on your Enterprise Search deployment to extract searchable content from binary files, like PDFs and Word documents. This setting applies to all new Elasticsearch indices created by an Enterprise Search ingestion mechanism.',
}
)}
link={
<EuiLink href="TODO TODO TODO TODO" external>
{i18n.translate('xpack.enterpriseSearch.content.settings.contactExtraction.link', {
defaultMessage: 'Learn more about content extraction',
})}
</EuiLink>
}
onChange={() =>
setPipeline({
...pipelineState,
extract_binary_content: !pipelineState.extract_binary_content,
})
}
title={i18n.translate('xpack.enterpriseSearch.content.settings.contentExtraction.title', {
defaultMessage: 'Deployment wide content extraction',
})}
value={extractBinaryContent}
/>
<EuiSpacer size="s" />
<SettingsPanel
description={i18n.translate(
'xpack.enterpriseSearch.content.settings.whiteSpaceReduction.description',
{
defaultMessage:
'Whitespace reduction will strip your full-text content of whitespace by default.',
}
)}
link={
<EuiLink href="TODO TODO TODO TODO" external>
{i18n.translate('xpack.enterpriseSearch.content.settings.whitespaceReduction.link', {
defaultMessage: 'Learn more about whitespace reduction',
})}
</EuiLink>
}
onChange={() =>
setPipeline({
...pipelineState,
reduce_whitespace: !pipelineState.reduce_whitespace,
})
}
title={i18n.translate(
'xpack.enterpriseSearch.content.settings.whitespaceReduction.deploymentHeaderTitle',
{
defaultMessage: 'Deployment wide whitespace reduction',
}
)}
value={reduceWhitespace}
/>
<EuiSpacer size="s" />
<SettingsPanel
description={i18n.translate(
'xpack.enterpriseSearch.content.settings.mlInference.description',
{
defaultMessage:
'ML Inference Pipelines will run as part of your pipelines. You will have to configure processors for each index individually on its pipelines page.',
}
)}
link={
<EuiLink href="TODO TODO TODO TODO" external>
{i18n.translate('xpack.enterpriseSearch.content.settings.mlInference.link', {
defaultMessage: 'Learn more about content extraction',
})}
</EuiLink>
}
onChange={() =>
setPipeline({
...pipelineState,
run_ml_inference: !pipelineState.run_ml_inference,
})
}
title={i18n.translate(
'xpack.enterpriseSearch.content.settings.mlInference.deploymentHeaderTitle',
{
defaultMessage: 'Deployment wide ML Inference Pipelines extraction',
}
)}
value={runMLInference}
/>
</EnterpriseSearchContentPageTemplate>
);
};

View file

@ -0,0 +1,130 @@
/*
* 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 { kea, MakeLogicType } from 'kea';
import { isDeepEqual } from 'react-use/lib/util';
import { i18n } from '@kbn/i18n';
import { DEFAULT_PIPELINE_VALUES } from '../../../../../common/constants';
import { Status } from '../../../../../common/types/api';
import { IngestPipelineParams } from '../../../../../common/types/connectors';
import { Actions } from '../../../shared/api_logic/create_api_logic';
import {
clearFlashMessages,
flashAPIErrors,
flashSuccessToast,
} from '../../../shared/flash_messages';
import {
FetchDefaultPipelineApiLogic,
FetchDefaultPipelineResponse,
} from '../../api/connector/get_default_pipeline_api_logic';
import {
PostDefaultPipelineArgs,
PostDefaultPipelineResponse,
UpdateDefaultPipelineApiLogic,
} from '../../api/connector/update_default_pipeline_api_logic';
type PipelinesActions = Pick<
Actions<PostDefaultPipelineArgs, PostDefaultPipelineResponse>,
'apiError' | 'apiSuccess' | 'makeRequest'
> & {
fetchDefaultPipeline: Actions<undefined, FetchDefaultPipelineResponse>['makeRequest'];
fetchDefaultPipelineError: Actions<undefined, FetchDefaultPipelineResponse>['apiError'];
fetchDefaultPipelineSuccess: Actions<undefined, FetchDefaultPipelineResponse>['apiSuccess'];
setPipeline(pipeline: IngestPipelineParams): {
pipeline: IngestPipelineParams;
};
};
interface PipelinesValues {
defaultPipeline: IngestPipelineParams;
fetchStatus: Status;
hasNoChanges: boolean;
isLoading: boolean;
pipelineState: IngestPipelineParams;
status: Status;
}
export const SettingsLogic = kea<MakeLogicType<PipelinesValues, PipelinesActions>>({
actions: {
setPipeline: (pipeline: IngestPipelineParams) => ({ pipeline }),
},
connect: {
actions: [
UpdateDefaultPipelineApiLogic,
['apiSuccess', 'apiError', 'makeRequest'],
FetchDefaultPipelineApiLogic,
[
'apiError as fetchDefaultPipelineError',
'apiSuccess as fetchDefaultPipelineSuccess',
'makeRequest as fetchDefaultPipeline',
],
],
values: [
FetchDefaultPipelineApiLogic,
['data as defaultPipeline', 'status as fetchStatus'],
UpdateDefaultPipelineApiLogic,
['status'],
],
},
events: ({ actions }) => ({
afterMount: () => {
actions.fetchDefaultPipeline(undefined);
},
}),
listeners: ({ actions }) => ({
apiError: (error) => flashAPIErrors(error),
apiSuccess: (pipeline) => {
flashSuccessToast(
i18n.translate(
'xpack.enterpriseSearch.content.indices.defaultPipelines.successToast.title',
{
defaultMessage: 'Default pipeline successfully updated',
}
)
);
actions.fetchDefaultPipelineSuccess(pipeline);
},
fetchDefaultPipelineSuccess: (pipeline) => {
actions.setPipeline(pipeline);
},
makeRequest: () => clearFlashMessages(),
}),
path: ['enterprise_search', 'content', 'settings'],
reducers: () => ({
pipelineState: [
DEFAULT_PIPELINE_VALUES,
{
setPipeline: (_, { pipeline }) => pipeline,
},
],
showModal: [
false,
{
apiSuccess: () => false,
closeModal: () => false,
openModal: () => true,
},
],
}),
selectors: ({ selectors }) => ({
hasNoChanges: [
() => [selectors.pipelineState, selectors.defaultPipeline],
(pipelineState: IngestPipelineParams, defaultPipeline: IngestPipelineParams) =>
isDeepEqual(pipelineState, defaultPipeline),
],
isLoading: [
() => [selectors.status, selectors.fetchStatus],
(status, fetchStatus) =>
[Status.LOADING, Status.IDLE].includes(fetchStatus) || status === Status.LOADING,
],
}),
});

View file

@ -0,0 +1,72 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiSplitPanel,
EuiSwitch,
EuiSwitchEvent,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface SettingsPanelProps {
description: string;
link: React.ReactNode;
onChange: (event: EuiSwitchEvent) => void;
title: string;
value: boolean;
}
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
description,
link,
onChange,
title,
value,
}) => (
<EuiSplitPanel.Outer hasBorder grow>
<EuiSplitPanel.Inner>
<EuiText size="m">
<h4>
<strong>{title}</strong>
</h4>
</EuiText>
<EuiSpacer />
<EuiText size="s">
<p>{description}</p>
<p>
{i18n.translate(
'xpack.enterpriseSearch.content.settings.contentExtraction.descriptionTwo',
{
defaultMessage:
'You can also enable or disable this feature for a specific index on the indexs configuration page.',
}
)}
</p>
</EuiText>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner grow={false} color="subdued">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiSwitch
checked={value}
label={i18n.translate('xpack.enterpriseSearch.content.settings.extractBinaryLabel', {
defaultMessage: 'Content extraction',
})}
onChange={onChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{link}</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
);

View file

@ -0,0 +1,34 @@
/*
* 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 { EuiCheckableCard, EuiText, EuiTitle } from '@elastic/eui';
export const SettingsCheckableCard: React.FC<{
checked: boolean;
description: string;
id: string;
label: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
}> = ({ checked, description, id, label, onChange }) => (
<EuiCheckableCard
label={
<EuiTitle size="xs">
<h4>{label}</h4>
</EuiTitle>
}
checkableType="checkbox"
onChange={onChange}
checked={checked}
id={id}
>
<EuiText color="subdued" size="s">
<p>{description}</p>
</EuiText>
</EuiCheckableCard>
);

View file

@ -41,6 +41,12 @@ describe('useEnterpriseSearchContentNav', () => {
id: 'search_indices',
name: 'Indices',
},
{
href: '/app/enterprise_search/content/settings',
id: 'settings',
items: undefined,
name: 'Settings',
},
],
name: 'Content',
},

View file

@ -18,7 +18,7 @@ import {
ENTERPRISE_SEARCH_OVERVIEW_PLUGIN,
WORKPLACE_SEARCH_PLUGIN,
} from '../../../../common/constants';
import { SEARCH_INDICES_PATH } from '../../enterprise_search_content/routes';
import { SEARCH_INDICES_PATH, SETTINGS_PATH } from '../../enterprise_search_content/routes';
import { KibanaLogic } from '../kibana';
import { generateNavLink } from './nav_link_helpers';
@ -51,6 +51,17 @@ export const useEnterpriseSearchNav = () => {
to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + SEARCH_INDICES_PATH,
}),
},
{
id: 'settings',
name: i18n.translate('xpack.enterpriseSearch.nav.contentSettingsTitle', {
defaultMessage: 'Settings',
}),
...generateNavLink({
shouldNotCreateHref: true,
shouldShowActiveForSubroutes: true,
to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + SETTINGS_PATH,
}),
},
],
name: i18n.translate('xpack.enterpriseSearch.nav.contentTitle', {
defaultMessage: 'Content',

View file

@ -39,6 +39,7 @@ export const config: PluginConfigDescriptor<ConfigType> = {
schema: configSchema,
};
export const CONNECTORS_INDEX = '.elastic-connectors';
export const CURRENT_CONNECTORS_INDEX = '.elastic-connectors-v1';
export const CONNECTORS_JOBS_INDEX = '.elastic-connectors-sync-jobs';
export const CONNECTORS_VERSION = '1';
export const CRAWLERS_INDEX = '.ent-search-actastic-crawler2_configurations';

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { IScopedClusterClient } from '@kbn/core/server';
export const getCustomPipelines = async (
indexName: string,
client: IScopedClusterClient
): Promise<IngestGetPipelineResponse> => {
try {
const pipelinesResponse = await client.asCurrentUser.ingest.getPipeline({
id: `${indexName}*`,
});
return pipelinesResponse;
} catch (error) {
// If we can't find anything, we return an empty object
return {};
}
};

View file

@ -0,0 +1,33 @@
/*
* 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 { CURRENT_CONNECTORS_INDEX } from '../..';
import { DEFAULT_PIPELINE_VALUES } from '../../../common/constants';
import { IngestPipelineParams } from '../../../common/types/connectors';
import { DefaultConnectorsPipelineMeta } from '../../index_management/setup_indices';
export const getDefaultPipeline = async (
client: IScopedClusterClient
): Promise<IngestPipelineParams> => {
const mapping = await client.asCurrentUser.indices.getMapping({
index: CURRENT_CONNECTORS_INDEX,
});
const meta: DefaultConnectorsPipelineMeta | undefined =
mapping[CURRENT_CONNECTORS_INDEX]?.mappings._meta?.pipeline;
const mappedMapping: IngestPipelineParams = meta
? {
extract_binary_content: meta.default_extract_binary_content,
name: meta.default_name,
reduce_whitespace: meta.default_reduce_whitespace,
run_ml_inference: meta.default_run_ml_inference,
}
: DEFAULT_PIPELINE_VALUES;
return mappedMapping;
};

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 { IScopedClusterClient } from '@kbn/core/server';
import { CURRENT_CONNECTORS_INDEX } from '../..';
import { IngestPipelineParams } from '../../../common/types/connectors';
import {
DefaultConnectorsPipelineMeta,
setupConnectorsIndices,
} from '../../index_management/setup_indices';
import { isIndexNotFoundException } from '../../utils/identify_exceptions';
export const updateDefaultPipeline = async (
client: IScopedClusterClient,
pipeline: IngestPipelineParams
) => {
try {
const mapping = await client.asCurrentUser.indices.getMapping({
index: CURRENT_CONNECTORS_INDEX,
});
const newPipeline: DefaultConnectorsPipelineMeta = {
default_extract_binary_content: pipeline.extract_binary_content,
default_name: pipeline.name,
default_reduce_whitespace: pipeline.reduce_whitespace,
default_run_ml_inference: pipeline.run_ml_inference,
};
await client.asCurrentUser.indices.putMapping({
_meta: { ...mapping[CURRENT_CONNECTORS_INDEX].mappings._meta, pipeline: newPipeline },
index: CURRENT_CONNECTORS_INDEX,
});
} catch (error) {
if (isIndexNotFoundException(error)) {
setupConnectorsIndices(client.asCurrentUser);
}
}
};

View file

@ -0,0 +1,24 @@
/*
* 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 { CONNECTORS_INDEX } from '../..';
import { IngestPipelineParams } from '../../../common/types/connectors';
export const updateConnectorPipeline = async (
client: IScopedClusterClient,
connectorId: string,
pipeline: IngestPipelineParams
) => {
await client.asCurrentUser.update({
doc: { pipeline },
id: connectorId,
index: CONNECTORS_INDEX,
});
};

View file

@ -14,6 +14,9 @@ import { fetchSyncJobsByConnectorId } from '../../lib/connectors/fetch_sync_jobs
import { startConnectorSync } from '../../lib/connectors/start_sync';
import { updateConnectorConfiguration } from '../../lib/connectors/update_connector_configuration';
import { updateConnectorScheduling } from '../../lib/connectors/update_connector_scheduling';
import { getDefaultPipeline } from '../../lib/pipelines/get_default_pipeline';
import { updateDefaultPipeline } from '../../lib/pipelines/update_default_pipeline';
import { updateConnectorPipeline } from '../../lib/pipelines/update_pipeline';
import { RouteDependencies } from '../../plugin';
import { createError } from '../../utils/create_error';
@ -137,4 +140,57 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) {
return response.ok({ body: result });
})
);
router.put(
{
path: '/internal/enterprise_search/connectors/{connectorId}/pipeline',
validate: {
body: schema.object({
extract_binary_content: schema.boolean(),
name: schema.string(),
reduce_whitespace: schema.boolean(),
run_ml_inference: schema.boolean(),
}),
params: schema.object({
connectorId: schema.string(),
}),
},
},
elasticsearchErrorHandler(log, async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
await updateConnectorPipeline(client, request.params.connectorId, request.body);
return response.ok();
})
);
router.put(
{
path: '/internal/enterprise_search/connectors/default_pipeline',
validate: {
body: schema.object({
extract_binary_content: schema.boolean(),
name: schema.string(),
reduce_whitespace: schema.boolean(),
run_ml_inference: schema.boolean(),
}),
},
},
elasticsearchErrorHandler(log, async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
await updateDefaultPipeline(client, request.body);
return response.ok();
})
);
router.get(
{
path: '/internal/enterprise_search/connectors/default_pipeline',
validate: {},
},
elasticsearchErrorHandler(log, async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
const result = await getDefaultPipeline(client);
return response.ok({ body: result });
})
);
}

View file

@ -20,9 +20,10 @@ import { fetchIndex } from '../../lib/indices/fetch_index';
import { fetchIndices } from '../../lib/indices/fetch_indices';
import { fetchMlInferencePipelineProcessors } from '../../lib/indices/fetch_ml_inference_pipeline_processors';
import { generateApiKey } from '../../lib/indices/generate_api_key';
import { createIndexPipelineDefinitions } from '../../lib/pipelines/create_pipeline_definitions';
import { getCustomPipelines } from '../../lib/pipelines/get_custom_pipelines';
import { RouteDependencies } from '../../plugin';
import { createError } from '../../utils/create_error';
import { createIndexPipelineDefinitions } from '../../utils/create_pipeline_definitions';
import { elasticsearchErrorHandler } from '../../utils/elasticsearch_error_handler';
import { isIndexNotFoundException } from '../../utils/identify_exceptions';
@ -266,6 +267,26 @@ export function registerIndexRoutes({
})
);
router.get(
{
path: '/internal/enterprise_search/indices/{indexName}/pipelines',
validate: {
params: schema.object({
indexName: schema.string(),
}),
},
},
elasticsearchErrorHandler(log, async (context, request, response) => {
const indexName = decodeURIComponent(request.params.indexName);
const { client } = (await context.core).elasticsearch;
const pipelines = await getCustomPipelines(indexName, client);
return response.ok({
body: pipelines,
headers: { 'content-type': 'application/json' },
});
})
);
router.get(
{
path: '/internal/enterprise_search/indices/{indexName}/ml_inference/pipeline_processors',