[Enterprise Search] Dedicated Connectors Page (#172361)

## Summary

- Adds a dedicated Connectors route and Table with search and pagination
- Updates basic functionality of Select Connectors Page (will follow-up
with another PR)

<img width="1289" alt="Screenshot 2023-12-01 at 17 10 22"
src="6ecea3e5-f696-4d05-813f-624d509cd37c">


### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Efe Gürkan YALAMAN 2023-12-05 16:31:18 +01:00 committed by GitHub
parent 2af7030b60
commit 45885a79a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1278 additions and 265 deletions

View file

@ -1504,6 +1504,8 @@ module.exports = {
'error',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true, varsIgnorePattern: '^_' },
],
'@kbn/i18n/strings_should_be_translated_with_i18n': 'warn',
'@kbn/i18n/strings_should_be_translated_with_formatted_message': 'warn',
},
},
/**

View file

@ -12,6 +12,7 @@ export const CONNECTORS_JOBS_INDEX = '.elastic-connectors-sync-jobs';
export const CURRENT_CONNECTORS_JOB_INDEX = '.elastic-connectors-sync-jobs-v1';
export const CONNECTORS_VERSION = 1;
export const CONNECTORS_ACCESS_CONTROL_INDEX_PREFIX = '.search-acl-filter-';
export const CRAWLER_SERVICE_TYPE = 'elastic-crawler';
export * from './components';
export * from './connectors';

View file

@ -13,7 +13,7 @@ import { OptimisticConcurrency } from '../types/optimistic_concurrency';
import { Connector, ConnectorDocument } from '../types/connectors';
import { isIndexNotFoundException } from '../utils/identify_exceptions';
import { CONNECTORS_INDEX } from '..';
import { CONNECTORS_INDEX, CRAWLER_SERVICE_TYPE } from '..';
import { isNotNullish } from '../utils/is_not_nullish';
export const fetchConnectorById = async (
@ -65,9 +65,33 @@ export const fetchConnectorByIndexName = async (
export const fetchConnectors = async (
client: ElasticsearchClient,
indexNames?: string[]
indexNames?: string[],
fetchOnlyCrawlers?: boolean,
searchQuery?: string
): Promise<Connector[]> => {
const query: QueryDslQueryContainer = indexNames
const q = searchQuery && searchQuery.length > 0 ? searchQuery : undefined;
const query: QueryDslQueryContainer = q
? {
bool: {
should: [
{
wildcard: {
name: {
value: `*${q}*`,
},
},
},
{
wildcard: {
index_name: {
value: `*${q}*`,
},
},
},
],
},
}
: indexNames
? { terms: { index_name: indexNames } }
: { match_all: {} };
@ -86,9 +110,18 @@ export const fetchConnectors = async (
accumulator = accumulator.concat(hits);
} while (hits.length >= 1000);
return accumulator
const result = accumulator
.map(({ _source, _id }) => (_source ? { ..._source, id: _id } : undefined))
.filter(isNotNullish);
if (fetchOnlyCrawlers !== undefined) {
return result.filter((hit) => {
return !fetchOnlyCrawlers
? hit.service_type !== CRAWLER_SERVICE_TYPE
: hit.service_type === CRAWLER_SERVICE_TYPE;
});
}
return result;
} catch (error) {
if (isIndexNotFoundException(error)) {
return [];

View file

@ -231,3 +231,6 @@ export const DEFAULT_PRODUCT_FEATURES: ProductFeatures = {
export const CONNECTORS_ACCESS_CONTROL_INDEX_PREFIX = '.search-acl-filter-';
export const PLUGIN_ID = 'enterpriseSearch';
export const CONNECTOR_NATIVE_TYPE = 'native';
export const CONNECTOR_CLIENTS_TYPE = 'connector_clients';

View file

@ -12,6 +12,7 @@ export enum ErrorCode {
CONNECTOR_DOCUMENT_ALREADY_EXISTS = 'connector_document_already_exists',
CRAWLER_ALREADY_EXISTS = 'crawler_already_exists',
DOCUMENT_NOT_FOUND = 'document_not_found',
EXPENSIVE_QUERY_NOT_ALLOWED_ERROR = 'expensive_queries_not_allowed',
INDEX_ALREADY_EXISTS = 'index_already_exists',
INDEX_NOT_FOUND = 'index_not_found',
MAPPING_UPDATE_FAILED = 'mapping_update_failed',
@ -20,10 +21,10 @@ export enum ErrorCode {
PIPELINE_IS_IN_USE = 'pipeline_is_in_use',
PIPELINE_NOT_FOUND = 'pipeline_not_found',
RESOURCE_NOT_FOUND = 'resource_not_found',
SEARCH_APPLICATION_ALIAS_NOT_FOUND = 'search_application_alias_not_found',
SEARCH_APPLICATION_ALREADY_EXISTS = 'search_application_already_exists',
SEARCH_APPLICATION_NAME_INVALID = 'search_application_name_invalid',
SEARCH_APPLICATION_NOT_FOUND = 'search_application_not_found',
SEARCH_APPLICATION_ALIAS_NOT_FOUND = 'search_application_alias_not_found',
UNAUTHORIZED = 'unauthorized',
UNCAUGHT_EXCEPTION = 'uncaught_exception',
}

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 { Connector } from '@kbn/search-connectors';
import { Meta } from '../../../../../common/types/pagination';
import { createApiLogic, Actions } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface FetchConnectorsApiLogicArgs {
fetchCrawlersOnly: boolean;
from: number;
searchQuery?: string;
size: number;
}
export interface FetchConnectorsApiLogicResponse {
connectors: Connector[];
counts: Record<string, number>;
isInitialRequest: boolean;
meta: Meta;
}
export const fetchConnectors = async ({
fetchCrawlersOnly,
from,
size,
searchQuery,
}: FetchConnectorsApiLogicArgs): Promise<FetchConnectorsApiLogicResponse> => {
const isInitialRequest = from === 0 && !searchQuery;
const route = '/internal/enterprise_search/connectors';
const query = { fetchCrawlersOnly, from, searchQuery, size };
const response = await HttpLogic.values.http.get<FetchConnectorsApiLogicResponse>(route, {
query,
});
return { ...response, isInitialRequest };
};
export const FetchConnectorsApiLogic = createApiLogic(
['fetch_connectors_api_logic'],
fetchConnectors
);
export type FetchConnectorsApiLogicActions = Actions<
FetchConnectorsApiLogicArgs,
FetchConnectorsApiLogicResponse
>;

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, { useEffect } from 'react';
import { useActions, useValues } from 'kea';
import {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiSplitPanel,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FetchSyncJobsStatsApiLogic } from '../../api/stats/fetch_sync_jobs_stats_api_logic';
export const ConnectorStats: React.FC = () => {
const { makeRequest } = useActions(FetchSyncJobsStatsApiLogic);
const { data } = useValues(FetchSyncJobsStatsApiLogic);
useEffect(() => {
makeRequest({});
}, []);
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiSplitPanel.Outer hasShadow={false} hasBorder>
<EuiSplitPanel.Inner>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="xxxs">
<h4>
{i18n.translate(
'xpack.enterpriseSearch.connectorStats.h4.connectorSummaryLabel',
{ defaultMessage: 'Connector summary' }
)}
</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
{i18n.translate('xpack.enterpriseSearch.connectorStats.connectorsTextLabel', {
defaultMessage: '{count} connectors',
values: {
count: (data?.connected || 0) + (data?.incomplete || 0),
},
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner grow={false} color="subdued">
<EuiBadge color="success">
{i18n.translate('xpack.enterpriseSearch.connectorStats.connectedBadgeLabel', {
defaultMessage: '{number} connected',
values: {
number: data?.connected || 0,
},
})}
</EuiBadge>
<EuiBadge color="warning">
{i18n.translate('xpack.enterpriseSearch.connectorStats.incompleteBadgeLabel', {
defaultMessage: '{number} incomplete',
values: {
number: data?.incomplete || 0,
},
})}
</EuiBadge>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</EuiFlexItem>
<EuiFlexItem>
<EuiSplitPanel.Outer hasShadow={false} hasBorder>
<EuiSplitPanel.Inner>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="xxxs">
<h4>
{i18n.translate('xpack.enterpriseSearch.connectorStats.h4.syncsStatusLabel', {
defaultMessage: 'Syncs status',
})}
</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
{i18n.translate('xpack.enterpriseSearch.connectorStats.runningSyncsTextLabel', {
defaultMessage: '{syncs} running syncs',
values: {
syncs: data?.in_progress,
},
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner grow={false} color="subdued">
{i18n.translate('xpack.enterpriseSearch.connectorStats.idleSyncsOrphanedSyncsLabel', {
defaultMessage:
'{idleCount} Idle syncs / {orphanedCount} Orphaned syncs / {errorCount} Sync errors',
values: {
errorCount: data?.errors || 0,
idleCount: data?.idle,
orphanedCount: data?.orphaned_jobs,
},
})}
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</EuiFlexItem>
</EuiFlexGroup>
);
};

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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import { CONNECTORS } from '../search_index/connector/constants';
export interface ConnectorTypeProps {
serviceType: string;
}
export const ConnectorType: React.FC<ConnectorTypeProps> = ({ serviceType }) => {
const connector = CONNECTORS.find((c) => c.serviceType === serviceType);
return (
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
{connector && connector.icon && (
<EuiFlexItem grow={false}>
<EuiIcon type={connector.icon} size="m" />
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiText size="s">
<p>{connector?.name ?? '-'}</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,149 @@
/*
* 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 { useActions, useValues } from 'kea';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSearchBar,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { KibanaLogic } from '../../../shared/kibana';
import { handlePageChange } from '../../../shared/table_pagination';
import {
NEW_INDEX_SELECT_CONNECTOR_CLIENTS_PATH,
NEW_INDEX_SELECT_CONNECTOR_NATIVE_PATH,
NEW_INDEX_SELECT_CONNECTOR_PATH,
} from '../../routes';
import { EnterpriseSearchContentPageTemplate } from '../layout';
import { SelectConnector } from '../new_index/select_connector/select_connector';
import { ConnectorStats } from './connector_stats';
import { ConnectorsLogic } from './connectors_logic';
import { ConnectorsTable } from './connectors_table';
export const baseBreadcrumbs = [
i18n.translate('xpack.enterpriseSearch.content.connectors.breadcrumb', {
defaultMessage: 'Connectors',
}),
];
export const Connectors: React.FC = () => {
const { fetchConnectors, onPaginate, setIsFirstRequest } = useActions(ConnectorsLogic);
const { data, isLoading, searchParams, isEmpty, connectors } = useValues(ConnectorsLogic);
const [searchQuery, setSearchValue] = useState('');
useEffect(() => {
setIsFirstRequest();
}, []);
useEffect(() => {
fetchConnectors({ ...searchParams, searchQuery });
}, [searchParams.from, searchParams.size, searchQuery]);
return (
<>
{!isLoading && isEmpty ? (
<SelectConnector />
) : (
<EnterpriseSearchContentPageTemplate
pageChrome={baseBreadcrumbs}
pageViewTelemetry="Connectors"
isLoading={isLoading}
pageHeader={{
pageTitle: i18n.translate('xpack.enterpriseSearch.connectors.title', {
defaultMessage: 'Elasticsearch connectors',
}),
rightSideGroupProps: {
gutterSize: 's',
},
rightSideItems: isLoading
? []
: [
<EuiButton
key="newConnector"
color="primary"
iconType="plusInCircle"
fill
onClick={() => {
KibanaLogic.values.navigateToUrl(NEW_INDEX_SELECT_CONNECTOR_PATH);
}}
>
<FormattedMessage
id="xpack.enterpriseSearch.connectors.newConnectorButtonLabel"
defaultMessage="New Connector"
/>
</EuiButton>,
<EuiButton
key="newConnectorNative"
onClick={() => {
KibanaLogic.values.navigateToUrl(NEW_INDEX_SELECT_CONNECTOR_NATIVE_PATH);
}}
>
{i18n.translate(
'xpack.enterpriseSearch.connectors.newNativeConnectorButtonLabel',
{ defaultMessage: 'New Native Connector' }
)}
</EuiButton>,
<EuiButton
key="newConnectorClient"
onClick={() => {
KibanaLogic.values.navigateToUrl(NEW_INDEX_SELECT_CONNECTOR_CLIENTS_PATH);
}}
>
{i18n.translate(
'xpack.enterpriseSearch.connectors.newConnectorsClientButtonLabel',
{ defaultMessage: 'New Connectors Client' }
)}
</EuiButton>,
],
}}
>
<ConnectorStats />
<EuiSpacer />
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.enterpriseSearch.connectorsTable.h2.availableConnectorsLabel"
defaultMessage="Available Connectors"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiSearchBar
query={searchQuery}
box={{ incremental: true, placeholder: 'Filter Connectors' }}
aria-label={i18n.translate(
'xpack.enterpriseSearch.connectorsTable.euiSearchBar.filterConnectorsLabel',
{ defaultMessage: 'Filter Connectors' }
)}
onChange={(event) => setSearchValue(event.queryText)}
/>
</EuiFlexItem>
<ConnectorsTable
items={connectors || []}
meta={data?.meta}
onChange={handlePageChange(onPaginate)}
/>
</EuiFlexGroup>
</EnterpriseSearchContentPageTemplate>
)}
</>
);
};

View file

@ -0,0 +1,145 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { Connector } from '@kbn/search-connectors/types';
import { Status } from '../../../../../common/types/api';
import { Meta } from '../../../../../common/types/pagination';
import {
FetchConnectorsApiLogic,
FetchConnectorsApiLogicActions,
} from '../../api/connector/fetch_connectors.api';
export type ConnectorViewItem = Connector & { docsCount?: number };
export interface ConnectorsActions {
apiError: FetchConnectorsApiLogicActions['apiError'];
apiSuccess: FetchConnectorsApiLogicActions['apiSuccess'];
fetchConnectors({
fetchCrawlersOnly,
from,
size,
searchQuery,
}: {
fetchCrawlersOnly: boolean;
from: number;
searchQuery?: string;
size: number;
}): {
fetchCrawlersOnly: boolean;
from: number;
searchQuery?: string;
size: number;
};
makeRequest: FetchConnectorsApiLogicActions['makeRequest'];
onPaginate(newPageIndex: number): { newPageIndex: number };
setIsFirstRequest(): void;
}
export interface ConnectorsValues {
connectors: ConnectorViewItem[];
data: typeof FetchConnectorsApiLogic.values.data;
isEmpty: boolean;
isFetchConnectorsDetailsLoading: boolean;
isFirstRequest: boolean;
isLoading: boolean;
meta: Meta;
searchParams: {
fetchCrawlersOnly: boolean;
from: number;
searchQuery?: string;
size: number;
};
status: typeof FetchConnectorsApiLogic.values.status;
}
export const ConnectorsLogic = kea<MakeLogicType<ConnectorsValues, ConnectorsActions>>({
actions: {
fetchConnectors: ({ fetchCrawlersOnly, from, size, searchQuery }) => ({
fetchCrawlersOnly,
from,
searchQuery,
size,
}),
onPaginate: (newPageIndex) => ({ newPageIndex }),
setIsFirstRequest: true,
},
connect: {
actions: [FetchConnectorsApiLogic, ['makeRequest', 'apiSuccess', 'apiError']],
values: [FetchConnectorsApiLogic, ['data', 'status']],
},
listeners: ({ actions }) => ({
fetchConnectors: async (input, breakpoint) => {
await breakpoint(150);
actions.makeRequest(input);
},
}),
path: ['enterprise_search', 'content', 'connectors_logic'],
reducers: () => ({
isFirstRequest: [
true,
{
apiError: () => false,
apiSuccess: () => false,
setIsFirstRequest: () => true,
},
],
searchParams: [
{
fetchCrawlersOnly: false,
from: 0,
searchQuery: '',
size: 10,
},
{
apiSuccess: ({ fetchCrawlersOnly, searchQuery }, { meta }) => ({
fetchCrawlersOnly,
from: meta.page.from,
searchQuery,
size: meta.page.size,
}),
onPaginate: (state, { newPageIndex }) => ({
...state,
from: (newPageIndex - 1) * state.size,
}),
},
],
}),
selectors: ({ selectors }) => ({
connectors: [
() => [selectors.data],
(data: ConnectorsValues['data']) => {
return (
data?.connectors.map((connector) => {
const indexName = connector.index_name;
if (indexName) {
return {
...connector,
docsCount: data?.counts[indexName],
};
}
return connector;
}) || []
);
},
],
isEmpty: [
() => [selectors.data],
(data) =>
(data?.isInitialRequest && data?.connectors && data.connectors.length === 0) ?? false,
],
isLoading: [
() => [selectors.status, selectors.isFirstRequest],
(status, isFirstRequest) => [Status.LOADING, Status.IDLE].includes(status) && isFirstRequest,
],
meta: [
() => [selectors.data],
(data) => data?.meta ?? { page: { from: 0, size: 20, total: 0 } },
],
}),
});

View file

@ -0,0 +1,180 @@
/*
* 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 { useValues } from 'kea';
import {
CriteriaWithPagination,
EuiBadge,
EuiBasicTable,
EuiBasicTableColumn,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Connector } from '@kbn/search-connectors';
import { Meta } from '../../../../../common/types/pagination';
import { generateEncodedPath } from '../../../shared/encode_path_params';
import { KibanaLogic } from '../../../shared/kibana';
import { EuiLinkTo } from '../../../shared/react_router_helpers/eui_components';
import { SEARCH_INDEX_PATH } from '../../routes';
import {
connectorStatusToColor,
connectorStatusToText,
} from '../../utils/connector_status_helpers';
import { ConnectorType } from './connector_type';
import { ConnectorViewItem } from './connectors_logic';
interface ConnectorsTableProps {
isLoading?: boolean;
items: ConnectorViewItem[];
meta?: Meta;
onChange: (criteria: CriteriaWithPagination<Connector>) => void;
}
export const ConnectorsTable: React.FC<ConnectorsTableProps> = ({
items,
meta = {
page: {
from: 0,
size: 10,
total: 0,
},
},
onChange,
isLoading,
}) => {
const { navigateToUrl } = useValues(KibanaLogic);
const columns: Array<EuiBasicTableColumn<ConnectorViewItem>> = [
{
field: 'name',
name: i18n.translate(
'xpack.enterpriseSearch.content.connectors.connectorTable.columns.connectorName',
{
defaultMessage: 'Connector name',
}
),
width: '25%',
},
{
field: 'index_name',
name: i18n.translate(
'xpack.enterpriseSearch.content.connectors.connectorTable.columns.indexName',
{
defaultMessage: 'Index name',
}
),
render: (indexName: string) =>
indexName ? (
<EuiLinkTo to={generateEncodedPath(SEARCH_INDEX_PATH, { indexName })}>
{indexName}
</EuiLinkTo>
) : (
'--'
),
width: '25%',
},
{
field: 'docsCount',
name: i18n.translate(
'xpack.enterpriseSearch.content.connectors.connectorTable.columns.docsCount',
{
defaultMessage: 'Docs count',
}
),
truncateText: true,
},
{
field: 'service_type',
name: i18n.translate(
'xpack.enterpriseSearch.content.connectors.connectorTable.columns.type',
{
defaultMessage: 'Connector type',
}
),
render: (serviceType: string) => <ConnectorType serviceType={serviceType} />,
truncateText: true,
width: '25%',
},
{
field: 'status',
name: i18n.translate(
'xpack.enterpriseSearch.content.connectors.connectorTable.columns.status',
{
defaultMessage: 'Ingestion status',
}
),
render: (connector: Connector) => {
const label = connectorStatusToText(connector.status);
return <EuiBadge color={connectorStatusToColor(connector.status)}>{label}</EuiBadge>;
},
truncateText: true,
},
{
actions: [
{
description: i18n.translate(
'xpack.enterpriseSearch.content.connectors.connectorTable.columns.actions.viewIndex',
{ defaultMessage: 'View this connector' }
),
enabled: (connector) => !!connector.index_name,
icon: 'eye',
isPrimary: false,
name: (connector) =>
i18n.translate(
'xpack.enterpriseSearch.content.connectors.connectorsTable.columns.actions.viewIndex.caption',
{
defaultMessage: 'View index {connectorName}',
values: {
connectorName: connector.name,
},
}
),
onClick: (connector) => {
navigateToUrl(
generateEncodedPath(SEARCH_INDEX_PATH, {
indexName: connector.index_name || '',
})
);
},
type: 'icon',
},
],
name: i18n.translate(
'xpack.enterpriseSearch.content.connectors.connectorTable.columns.actions',
{
defaultMessage: 'Actions',
}
),
},
];
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiBasicTable
items={items}
columns={columns}
onChange={onChange}
tableLayout="fixed"
loading={isLoading}
pagination={{
pageIndex: meta.page.from / (meta.page.size || 1),
pageSize: meta.page.size,
showPerPageOptions: false,
totalItemCount: meta.page.total,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -33,10 +33,14 @@ import { errorToText } from '../utils/error_to_text';
import { AddConnectorLogic } from './add_connector_logic';
interface MethodConnectorProps {
isNative?: boolean;
serviceType: string;
}
export const MethodConnector: React.FC<MethodConnectorProps> = ({ serviceType }) => {
export const MethodConnector: React.FC<MethodConnectorProps> = ({
serviceType,
isNative: isNativeProp = true,
}) => {
const { apiReset, makeRequest } = useActions(AddConnectorApiLogic);
const { error, status } = useValues(AddConnectorApiLogic);
const { isModalVisible } = useValues(AddConnectorLogic);
@ -45,13 +49,15 @@ export const MethodConnector: React.FC<MethodConnectorProps> = ({ serviceType })
const { isCloud } = useValues(KibanaLogic);
const { hasPlatinumLicense } = useValues(LicensingLogic);
const isNative =
const isNativeAvailable =
Boolean(NATIVE_CONNECTORS.find((connector) => connector.serviceType === serviceType)) &&
isCloud;
const isBeta = Boolean(
BETA_CONNECTORS.find((connector) => connector.serviceType === serviceType)
);
const isNative = isNativeAvailable && isNativeProp;
const isGated = isNative && !isCloud && !hasPlatinumLicense;
const { makeRequest: fetchCloudHealth } = useActions(FetchCloudHealthApiLogic);

View file

@ -12,7 +12,11 @@ import { useLocation, useParams } from 'react-router-dom';
import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { INGESTION_METHOD_IDS } from '../../../../../common/constants';
import {
CONNECTOR_CLIENTS_TYPE,
CONNECTOR_NATIVE_TYPE,
INGESTION_METHOD_IDS,
} from '../../../../../common/constants';
import { parseQueryParams } from '../../../shared/query_params';
import { EnterpriseSearchContentPageTemplate } from '../layout/page_template';
@ -83,17 +87,32 @@ function getDescription(method: string): string {
}
}
const parseIsNativeParam = (queryString: string | string[] | null): boolean | undefined => {
const parsedStr = Array.isArray(queryString) ? queryString[0] : queryString;
if (parsedStr === CONNECTOR_NATIVE_TYPE) return true;
if (parsedStr === CONNECTOR_CLIENTS_TYPE) return false;
return undefined;
};
export const NewSearchIndexPage: React.FC = () => {
const type = decodeURIComponent(useParams<{ type: string }>().type);
const { search } = useLocation();
const { service_type: inputServiceType } = parseQueryParams(search);
const { service_type: inputServiceType, connector_type: inputConnectorType } =
parseQueryParams(search);
const serviceType = Array.isArray(inputServiceType)
? inputServiceType[0]
: inputServiceType || '';
const isNative = parseIsNativeParam(inputConnectorType);
return (
<EnterpriseSearchContentPageTemplate
pageChrome={[...baseBreadcrumbs, 'New search index']}
pageChrome={[
...baseBreadcrumbs,
i18n.translate('xpack.enterpriseSearch.content.new_index.breadcrumbs', {
defaultMessage: 'New search index',
}),
]}
pageViewTelemetry="New Index"
isLoading={false}
pageHeader={{
@ -112,7 +131,9 @@ export const NewSearchIndexPage: React.FC = () => {
<>
{type === INGESTION_METHOD_IDS.CRAWLER && <MethodCrawler />}
{type === INGESTION_METHOD_IDS.API && <MethodApi />}
{type === INGESTION_METHOD_IDS.CONNECTOR && <MethodConnector serviceType={serviceType} />}
{type === INGESTION_METHOD_IDS.CONNECTOR && (
<MethodConnector serviceType={serviceType} isNative={isNative} />
)}
</>
}
</EnterpriseSearchContentPageTemplate>

View file

@ -5,104 +5,171 @@
* 2.0.
*/
import React, { useState } from 'react';
import React, { MouseEvent, useState } from 'react';
import { css } from '@emotion/react';
import {
EuiBadge,
EuiButtonIcon,
EuiCheckableCard,
EuiCheckableCardProps,
EuiCard,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiPopover,
EuiSpacer,
EuiText,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { BETA_LABEL, NATIVE_LABEL } from '../../../../shared/constants';
import { BETA_LABEL, NATIVE_LABEL, CONNECTOR_CLIENT_LABEL } from '../../../../shared/constants';
import './connector_checkable.scss';
import { PlatinumLicensePopover } from '../../shared/platinum_license_popover/platinum_license_popover';
export type ConnectorCheckableProps = Omit<
EuiCheckableCardProps,
'id' | 'label' | 'name' | 'value'
> & {
export interface ConnectorCheckableProps {
documentationUrl: string | undefined;
icon: string;
iconType: string;
isBeta: boolean;
isDisabled: boolean;
isTechPreview: boolean;
name: string;
onConnectorSelect: (isNative?: boolean) => void;
serviceType: string;
showNativeBadge: boolean;
};
}
export const ConnectorCheckable: React.FC<ConnectorCheckableProps> = ({
disabled,
isDisabled,
documentationUrl,
icon,
iconType,
isBeta,
isTechPreview,
showNativeBadge,
name,
onConnectorSelect,
serviceType,
...props
showNativeBadge,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const { euiTheme } = useEuiTheme();
const [isLicensePopoverOpen, setIsLicensePopoverOpen] = useState(false);
const [isNativePopoverOpen, setIsNativePopoverOpen] = useState(false);
return (
<EuiCheckableCard
{...props}
disabled={disabled}
<EuiCard
onClick={() => onConnectorSelect()}
hasBorder
id={`checkableCard-${serviceType}`}
className="connectorCheckable"
css={
showNativeBadge
? css`
box-shadow: 8px 9px 0px -1px ${euiTheme.colors.lightestShade},
8px 9px 0px 0px ${euiTheme.colors.lightShade};
`
: undefined
}
layout="horizontal"
data-telemetry-id={`entSearchContent-connector-selectConnector-${serviceType}-select`}
label={
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
{icon && (
<EuiFlexItem grow={false}>
<EuiIcon type={icon} />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
{disabled ? (
<EuiText color="disabledText" size="xs">
<h3>{name}</h3>
</EuiText>
) : (
<EuiTitle size="xs">
<h2>{name}</h2>
</EuiTitle>
)}
icon={iconType ? <EuiIcon type={iconType} size="l" /> : undefined}
title={
<EuiFlexGroup gutterSize="s" responsive={false} justifyContent="spaceAround">
<EuiFlexItem grow>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
{isDisabled ? (
<EuiText color="disabledText" size="xs">
<h3>{name}</h3>
</EuiText>
) : (
<EuiTitle size="xs">
<h2>{name}</h2>
</EuiTitle>
)}
</EuiFlexItem>
{isDisabled && (
<EuiFlexItem grow={false}>
<PlatinumLicensePopover
button={
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.enterpriseSearch.content.newIndex.selectConnector.openPopoverLabel',
{
defaultMessage: 'Open licensing popover',
}
)}
iconType="questionInCircle"
onClick={() => setIsLicensePopoverOpen(!isLicensePopoverOpen)}
/>
}
closePopover={() => setIsLicensePopoverOpen(false)}
isPopoverOpen={isLicensePopoverOpen}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
{disabled && (
{showNativeBadge && (
<EuiFlexItem grow={false}>
<PlatinumLicensePopover
<EuiPopover
button={
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.enterpriseSearch.content.newIndex.selectConnector.openPopoverLabel',
{
defaultMessage: 'Open licensing popover',
}
)}
iconType="questionInCircle"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
display="base"
isDisabled={isDisabled}
color="primary"
iconType="boxesHorizontal"
onClick={(e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setIsNativePopoverOpen(true);
}}
/>
}
closePopover={() => setIsPopoverOpen(false)}
isPopoverOpen={isPopoverOpen}
/>
isOpen={isNativePopoverOpen}
closePopover={() => {
setIsNativePopoverOpen(false);
}}
>
<EuiContextMenuPanel
size="xs"
items={[
<EuiContextMenuItem
key="native"
onClick={(e) => {
e.stopPropagation();
onConnectorSelect(true);
}}
>
{i18n.translate(
'xpack.enterpriseSearch.connectorCheckable.setupANativeConnectorContextMenuItemLabel',
{ defaultMessage: 'Setup a Native Connector' }
)}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="client"
onClick={(e) => {
e.stopPropagation();
onConnectorSelect(false);
}}
>
{i18n.translate(
'xpack.enterpriseSearch.connectorCheckable.setupAConnectorClientContextMenuItemLabel',
{ defaultMessage: 'Setup a Connector Client' }
)}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
</EuiFlexItem>
)}
</EuiFlexGroup>
}
name={name}
value={serviceType}
>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiSpacer size="s" />
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiFlexGroup
direction="row"
@ -110,23 +177,23 @@ export const ConnectorCheckable: React.FC<ConnectorCheckableProps> = ({
justifyContent="flexStart"
responsive={false}
>
{showNativeBadge && (
<EuiFlexItem grow={false}>
<EuiBadge isDisabled={disabled}>
<EuiText size="xs">{NATIVE_LABEL}</EuiText>
</EuiBadge>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiBadge isDisabled={isDisabled}>
<EuiText size="xs">
{showNativeBadge ? NATIVE_LABEL : CONNECTOR_CLIENT_LABEL}
</EuiText>
</EuiBadge>
</EuiFlexItem>
{isBeta && (
<EuiFlexItem grow={false}>
<EuiBadge color="hollow" isDisabled={disabled}>
<EuiBadge color="hollow" isDisabled={isDisabled}>
<EuiText size="xs">{BETA_LABEL}</EuiText>
</EuiBadge>
</EuiFlexItem>
)}
{isTechPreview && (
<EuiFlexItem grow={false}>
<EuiBadge color="hollow" iconType="beaker" isDisabled={disabled}>
<EuiBadge color="hollow" iconType="beaker" isDisabled={isDisabled}>
<EuiText size="xs">
{i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.connectorCheckable.techPreviewLabel',
@ -155,6 +222,6 @@ export const ConnectorCheckable: React.FC<ConnectorCheckableProps> = ({
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiCheckableCard>
</EuiCard>
);
};

View file

@ -9,35 +9,36 @@ import React, { useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { css } from '@emotion/react';
import { useValues } from 'kea';
import {
EuiBadge,
EuiButton,
EuiCallOut,
EuiFacetButton,
EuiFacetGroup,
EuiFieldSearch,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormFieldset,
EuiLink,
EuiHorizontalRule,
EuiPanel,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { INGESTION_METHOD_IDS } from '../../../../../../common/constants';
import {
BACK_BUTTON_LABEL,
CONTINUE_BUTTON_LABEL,
LEARN_MORE_LINK,
} from '../../../../shared/constants';
import { docLinks } from '../../../../shared/doc_links';
CONNECTOR_CLIENTS_TYPE,
CONNECTOR_NATIVE_TYPE,
INGESTION_METHOD_IDS,
} from '../../../../../../common/constants';
import { BACK_BUTTON_LABEL } from '../../../../shared/constants';
import { generateEncodedPath } from '../../../../shared/encode_path_params';
import { KibanaLogic } from '../../../../shared/kibana';
@ -53,14 +54,33 @@ import { baseBreadcrumbs } from '../../search_indices';
import { ConnectorCheckable } from './connector_checkable';
export type ConnectorFilter = typeof CONNECTOR_NATIVE_TYPE | typeof CONNECTOR_CLIENTS_TYPE;
export const parseConnectorFilter = (filter: string | string[] | null): ConnectorFilter | null => {
const temp = Array.isArray(filter) ? filter[0] : filter ?? null;
if (!temp) return null;
if (temp === CONNECTOR_CLIENTS_TYPE) {
return CONNECTOR_CLIENTS_TYPE;
}
if (temp === CONNECTOR_NATIVE_TYPE) {
return CONNECTOR_NATIVE_TYPE;
}
return null;
};
export const SelectConnector: React.FC = () => {
const { search } = useLocation();
const { isCloud } = useValues(KibanaLogic);
const { hasPlatinumLicense } = useValues(LicensingLogic);
const hasNativeAccess = isCloud;
const { service_type: serviceType } = parseQueryParams(search);
const [useNativeFilter, setUseNativeFilter] = useState(false);
const [useNonGAFilter, setUseNonGAFilter] = useState(true);
const { filter } = parseQueryParams(search);
const [selectedConnectorFilter, setSelectedConnectorFilter] = useState<ConnectorFilter | null>(
parseConnectorFilter(filter)
);
const useNativeFilter = selectedConnectorFilter === CONNECTOR_NATIVE_TYPE;
const useClientsFilter = selectedConnectorFilter === CONNECTOR_CLIENTS_TYPE;
const [showTechPreview, setShowTechPreview] = useState(true);
const [showBeta, setShowBeta] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const filteredConnectors = useMemo(() => {
const nativeConnectors = hasNativeAccess
@ -75,17 +95,14 @@ export const SelectConnector: React.FC = () => {
: CONNECTORS.sort((a, b) => a.name.localeCompare(b.name));
const connectors = [...nativeConnectors, ...nonNativeConnectors];
return connectors
.filter((connector) =>
useNonGAFilter ? true : !connector.isBeta && !connector.isTechPreview
)
.filter((connector) => (showBeta ? true : !connector.isBeta))
.filter((connector) => (showTechPreview ? true : !connector.isTechPreview))
.filter((connector) => (useNativeFilter ? connector.isNative : true))
.filter((connector) =>
searchTerm ? connector.name.toLowerCase().includes(searchTerm.toLowerCase()) : true
);
}, [useNonGAFilter, useNativeFilter, searchTerm]);
const [selectedConnector, setSelectedConnector] = useState<string | null>(
Array.isArray(serviceType) ? serviceType[0] : serviceType ?? null
);
}, [showBeta, showTechPreview, useNativeFilter, searchTerm]);
const { euiTheme } = useEuiTheme();
return (
<EnterpriseSearchContentPageTemplate
@ -104,83 +121,22 @@ export const SelectConnector: React.FC = () => {
}),
}}
>
<EuiForm
component="form"
onSubmit={(event) => {
event.preventDefault();
KibanaLogic.values.navigateToUrl(
`${generateEncodedPath(NEW_INDEX_METHOD_PATH, {
type: INGESTION_METHOD_IDS.CONNECTOR,
})}?service_type=${selectedConnector}`
);
}}
>
<EuiFormFieldset
legend={{
children: (
<EuiCallOut
size="m"
title={i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.callout.title',
{ defaultMessage: 'Elastic connectors' }
)}
iconType="iInCircle"
>
<p>
<FormattedMessage
id="xpack.enterpriseSearch.content.indices.selectConnector.description.textcloud"
defaultMessage="{native} are available directly within Elastic Cloud deployments. No additional infrastructure is required. {learnMore}"
values={{
learnMore: (
<EuiLink target="_blank" href={docLinks.connectorsNative}>
{LEARN_MORE_LINK}
</EuiLink>
),
native: (
<b>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.callout.description.native',
{ defaultMessage: 'Native connectors' }
)}
</b>
),
}}
/>
<br />
<br />
<FormattedMessage
id="xpack.enterpriseSearch.content.indices.selectConnector.description.selfManaged.text"
defaultMessage="Deploy connectors on your own infrastructure as {connectorsClient}. You can also customize existing connector clients, or build your own using our connector framework. {learnMore}"
values={{
connectorsClient: (
<b>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.callout.description.connectorsClient',
{ defaultMessage: 'connector clients' }
)}
</b>
),
learnMore: (
<EuiLink target="_blank" href={docLinks.connectorsClients}>
{LEARN_MORE_LINK}
</EuiLink>
),
}}
/>
</p>
</EuiCallOut>
),
}}
>
<EuiFlexGroup>
{/* Only facet is for native connectors, so only show facets if we can show native connectors */}
{hasNativeAccess && (
<EuiFlexGroup>
{/* Only facet is for native connectors, so only show facets if we can show native connectors */}
{hasNativeAccess && (
<EuiFlexItem
grow={false}
css={css`
max-width: calc(${euiTheme.size.xxl} * 5);
`}
>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiFacetGroup>
<EuiFacetButton
quantity={CONNECTORS.length}
isSelected={!useNativeFilter}
onClick={() => setUseNativeFilter(!useNativeFilter)}
isSelected={!useNativeFilter && !useClientsFilter}
onClick={() => setSelectedConnectorFilter(null)}
>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.allConnectorsLabel',
@ -190,92 +146,181 @@ export const SelectConnector: React.FC = () => {
<EuiFacetButton
quantity={CONNECTORS.filter((connector) => connector.isNative).length}
isSelected={useNativeFilter}
onClick={() => setUseNativeFilter(!useNativeFilter)}
onClick={() =>
setSelectedConnectorFilter(!useNativeFilter ? CONNECTOR_NATIVE_TYPE : null)
}
>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.nativeLabel',
{ defaultMessage: 'Native connectors' }
)}
</EuiFacetButton>
<EuiFacetButton
quantity={CONNECTORS.length}
isSelected={useClientsFilter}
onClick={() =>
setSelectedConnectorFilter(!useClientsFilter ? CONNECTOR_CLIENTS_TYPE : null)
}
>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.connectorClients',
{ defaultMessage: 'Connector clients' }
)}
</EuiFacetButton>
</EuiFacetGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="s" />
<EuiPanel paddingSize="s" hasShadow={false}>
<EuiSwitch
checked={showBeta}
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.showBetaLabel',
{ defaultMessage: 'Display Beta connectors' }
)}
onChange={(e) => setShowBeta(e.target.checked)}
/>
<EuiSwitch
checked={showTechPreview}
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.showTechPreviewLabel',
{ defaultMessage: 'Display Tech Preview connectors' }
)}
onChange={(e) => setShowTechPreview(e.target.checked)}
/>
</EuiPanel>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="s" />
<EuiPanel paddingSize="s" hasShadow={false} grow={false}>
<EuiTitle size="xs">
<h4>
{i18n.translate(
'xpack.enterpriseSearch.selectConnector.nativeConnectorsTitleLabel',
{ defaultMessage: 'Native Connectors' }
)}
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EuiBadge iconSide="right" iconType="iInCircle">
{i18n.translate('xpack.enterpriseSearch.selectConnector.nativeBadgeLabel', {
defaultMessage: 'Native',
})}
</EuiBadge>
<EuiSpacer size="s" />
<EuiText size="xs" grow={false}>
<p>
{i18n.translate(
'xpack.enterpriseSearch.selectConnector.p.areAvailableDirectlyWithinLabel',
{
defaultMessage:
'Are available directly within Elastic Cloud deployments No additional infrastructure is required You can also convert them as self hosted Connectors client at any moment',
}
)}
</p>
</EuiText>
</EuiPanel>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="s" hasShadow={false} grow={false}>
<EuiTitle size="xs">
<h4>
{i18n.translate(
'xpack.enterpriseSearch.selectConnector.h4.connectorClientsLabel',
{ defaultMessage: 'Connector clients' }
)}
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EuiBadge iconSide="right" iconType="iInCircle">
{i18n.translate(
'xpack.enterpriseSearch.selectConnector.connectorClientBadgeLabel',
{ defaultMessage: 'Connector client' }
)}
</EuiBadge>
<EuiSpacer size="s" />
<EuiText size="xs" grow={false}>
<p>
{i18n.translate(
'xpack.enterpriseSearch.selectConnector.p.deployConnectorsOnYourLabel',
{
defaultMessage:
'Deploy connectors on your own infrastructure You can also customize existing Connector clients or build your own using our connector framework',
}
)}
</p>
</EuiText>
</EuiPanel>
<EuiSpacer size="s" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiFieldSearch
aria-label={i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.search.ariaLabel',
{ defaultMessage: 'Search through connectors' }
)}
isClearable
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.searchPlaceholder',
{ defaultMessage: 'Search' }
)}
value={searchTerm}
fullWidth
/>
<EuiSpacer size="s" />
<EuiFlexGrid columns={3}>
{filteredConnectors.map((connector) => (
<EuiFlexItem key={connector.serviceType} grow>
<ConnectorCheckable
isDisabled={(connector.platinumOnly && !(hasPlatinumLicense || isCloud)) ?? false}
iconType={connector.icon}
isBeta={connector.isBeta}
isTechPreview={Boolean(connector.isTechPreview)}
showNativeBadge={connector.isNative && hasNativeAccess}
name={connector.name}
serviceType={connector.serviceType}
onConnectorSelect={(isNative?: boolean) => {
const queryParam = new URLSearchParams();
queryParam.append('service_type', connector.serviceType);
if (isNative !== undefined) {
queryParam.append(
'connector_type',
isNative ? CONNECTOR_NATIVE_TYPE : CONNECTOR_CLIENTS_TYPE
);
}
KibanaLogic.values.navigateToUrl(
`${generateEncodedPath(NEW_INDEX_METHOD_PATH, {
type: INGESTION_METHOD_IDS.CONNECTOR,
})}?${queryParam.toString()}`
);
}}
documentationUrl={connector.docsUrl}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
<EuiFieldSearch
aria-label={i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.search.ariaLabel',
{ defaultMessage: 'Search through connectors' }
)}
isClearable
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.searchPlaceholder',
{ defaultMessage: 'Search' }
)}
value={searchTerm}
/>
<EuiSpacer size="s" />
<EuiSwitch
checked={useNonGAFilter}
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.selectConnector.showNonGALabel',
{ defaultMessage: 'Display Beta and Tech Preview connectors' }
)}
onChange={(e) => setUseNonGAFilter(e.target.checked)}
/>
<EuiSpacer size="s" />
<EuiFlexGrid columns={3}>
{filteredConnectors.map((connector) => (
<EuiFlexItem key={connector.serviceType} grow>
<ConnectorCheckable
disabled={connector.platinumOnly && !(hasPlatinumLicense || isCloud)}
icon={connector.icon}
isBeta={connector.isBeta}
isTechPreview={Boolean(connector.isTechPreview)}
showNativeBadge={connector.isNative && hasNativeAccess}
name={connector.name}
serviceType={connector.serviceType}
onChange={() => {
setSelectedConnector(connector.serviceType);
}}
documentationUrl={connector.docsUrl}
checked={selectedConnector === connector.serviceType}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
<span>
<EuiButton
data-telemetry-id="entSearchContent-connector-selectConnector-backButton"
color="primary"
onClick={() => KibanaLogic.values.navigateToUrl(NEW_INDEX_PATH)}
>
{BACK_BUTTON_LABEL}
</EuiButton>
</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>
<EuiButton
data-test-subj="entSearchContent-connector-selectConnector-selectAndConfigure"
data-telemetry-id="entSearchContent-connector-selectConnector-selectAndConfigure"
disabled={selectedConnector === null}
fill
color="primary"
type="submit"
>
{CONTINUE_BUTTON_LABEL}
</EuiButton>
</span>
</EuiFlexItem>
</EuiFlexGroup>
<span>
<EuiButton
data-telemetry-id="entSearchContent-connector-selectConnector-backButton"
color="primary"
onClick={() => KibanaLogic.values.navigateToUrl(NEW_INDEX_PATH)}
>
{BACK_BUTTON_LABEL}
</EuiButton>
</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormFieldset>
</EuiForm>
</EuiFlexItem>
</EuiFlexGroup>
</EnterpriseSearchContentPageTemplate>
);
};

View file

@ -10,7 +10,7 @@ import { Redirect } from 'react-router-dom';
import { useValues } from 'kea';
import { Routes, Route } from '@kbn/shared-ux-router';
import { Route, Routes } from '@kbn/shared-ux-router';
import { isVersionMismatch } from '../../../common/is_version_mismatch';
import { InitialAppData } from '../../../common/types';
@ -20,15 +20,17 @@ import { HttpLogic } from '../shared/http';
import { KibanaLogic } from '../shared/kibana';
import { VersionMismatchPage } from '../shared/version_mismatch';
import { Connectors } from './components/connectors/connectors';
import { NotFound } from './components/not_found';
import { SearchIndicesRouter } from './components/search_indices';
import { Settings } from './components/settings';
import {
SETUP_GUIDE_PATH,
CONNECTORS_PATH,
ERROR_STATE_PATH,
ROOT_PATH,
SEARCH_INDICES_PATH,
SETTINGS_PATH,
ERROR_STATE_PATH,
SETUP_GUIDE_PATH,
} from './routes';
export const EnterpriseSearchContent: React.FC<InitialAppData> = (props) => {
@ -74,6 +76,9 @@ export const EnterpriseSearchContentConfigured: React.FC<Required<InitialAppData
<Route path={SEARCH_INDICES_PATH}>
<SearchIndicesRouter />
</Route>
<Route path={CONNECTORS_PATH}>
<Connectors />
</Route>
<Route path={SETTINGS_PATH}>
<Settings />
</Route>

View file

@ -11,6 +11,7 @@ export const SETUP_GUIDE_PATH = '/setup_guide';
export const ERROR_STATE_PATH = '/error_state';
export const SEARCH_INDICES_PATH = `${ROOT_PATH}search_indices`;
export const CONNECTORS_PATH = `${ROOT_PATH}connectors`;
export const SETTINGS_PATH = `${ROOT_PATH}settings`;
export const NEW_INDEX_PATH = `${SEARCH_INDICES_PATH}/new_index`;
@ -19,6 +20,8 @@ export const NEW_API_PATH = `${NEW_INDEX_PATH}/api`;
export const NEW_ES_INDEX_PATH = `${NEW_INDEX_PATH}/elasticsearch`;
export const NEW_DIRECT_UPLOAD_PATH = `${NEW_INDEX_PATH}/upload`;
export const NEW_INDEX_SELECT_CONNECTOR_PATH = `${NEW_INDEX_PATH}/select_connector`;
export const NEW_INDEX_SELECT_CONNECTOR_NATIVE_PATH = `${NEW_INDEX_PATH}/select_connector?filter=native`;
export const NEW_INDEX_SELECT_CONNECTOR_CLIENTS_PATH = `${NEW_INDEX_PATH}/select_connector?filter=connector_clients`;
export const SEARCH_INDEX_PATH = `${SEARCH_INDICES_PATH}/:indexName`;
export const SEARCH_INDEX_TAB_PATH = `${SEARCH_INDEX_PATH}/:tabId`;

View file

@ -0,0 +1,50 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { ConnectorStatus } from '@kbn/search-connectors';
export function connectorStatusToText(connectorStatus: ConnectorStatus): string {
if (
connectorStatus === ConnectorStatus.CREATED ||
connectorStatus === ConnectorStatus.NEEDS_CONFIGURATION
) {
return i18n.translate(
'xpack.enterpriseSearch.content.searchIndices.connectorStatus.needsConfig.label',
{ defaultMessage: 'Needs Configuration' }
);
}
if (connectorStatus === ConnectorStatus.ERROR) {
return i18n.translate(
'xpack.enterpriseSearch.content.searchIndices.connectorStatus.connectorFailure.label',
{ defaultMessage: 'Connector Failure' }
);
}
if (connectorStatus === ConnectorStatus.CONFIGURED) {
return i18n.translate(
'xpack.enterpriseSearch.content.searchIndices.ingestionStatus.configured.label',
{ defaultMessage: 'Configured' }
);
}
return i18n.translate(
'xpack.enterpriseSearch.content.searchIndices.ingestionStatus.incomplete.label',
{ defaultMessage: 'Incomplete' }
);
}
export function connectorStatusToColor(
connectorStatus: ConnectorStatus
): 'warning' | 'danger' | 'success' {
if (connectorStatus === ConnectorStatus.CONNECTED) {
return 'success';
}
if (connectorStatus === ConnectorStatus.ERROR) {
return 'danger';
}
return 'warning';
}

View file

@ -46,3 +46,10 @@ export const OPTIONAL_LABEL = i18n.translate('xpack.enterpriseSearch.optionalLab
export const LEARN_MORE_LINK = i18n.translate('xpack.enterpriseSearch.learnMore.link', {
defaultMessage: 'Learn more',
});
export const CONNECTOR_CLIENT_LABEL = i18n.translate(
'xpack.enterpriseSearch.connectorClientLabel',
{
defaultMessage: 'Connector Client',
}
);

View file

@ -41,6 +41,12 @@ const baseNavItems = [
items: undefined,
name: 'Indices',
},
{
href: '/app/enterprise_search/content/connectors',
id: 'connectors',
items: undefined,
name: 'Connectors',
},
{
href: '/app/enterprise_search/content/settings',
id: 'settings',

View file

@ -24,7 +24,11 @@ import {
WORKPLACE_SEARCH_PLUGIN,
} from '../../../../common/constants';
import { SEARCH_APPLICATIONS_PATH, SearchApplicationViewTabs } from '../../applications/routes';
import { SEARCH_INDICES_PATH, SETTINGS_PATH } from '../../enterprise_search_content/routes';
import {
CONNECTORS_PATH,
SEARCH_INDICES_PATH,
SETTINGS_PATH,
} from '../../enterprise_search_content/routes';
import { KibanaLogic } from '../kibana';
import { generateNavLink } from './nav_link_helpers';
@ -63,6 +67,17 @@ export const useEnterpriseSearchNav = () => {
to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + SEARCH_INDICES_PATH,
}),
},
{
id: 'connectors',
name: i18n.translate('xpack.enterpriseSearch.nav.connectorsTitle', {
defaultMessage: 'Connectors',
}),
...generateNavLink({
shouldNotCreateHref: true,
shouldShowActiveForSubroutes: true,
to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + CONNECTORS_PATH,
}),
},
...(productFeatures.hasDefaultIngestPipeline
? [
{

View file

@ -8,6 +8,7 @@
import { schema } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import {
fetchConnectors,
fetchSyncJobsByConnectorId,
putUpdateNative,
updateConnectorConfiguration,
@ -25,6 +26,7 @@ import { cancelSyncs } from '@kbn/search-connectors/lib/cancel_syncs';
import { ErrorCode } from '../../../common/types/error_codes';
import { addConnector } from '../../lib/connectors/add_connector';
import { startSync } from '../../lib/connectors/start_sync';
import { fetchIndexCounts } from '../../lib/indices/fetch_index_counts';
import { getDefaultPipeline } from '../../lib/pipelines/get_default_pipeline';
import { updateDefaultPipeline } from '../../lib/pipelines/update_default_pipeline';
import { updateConnectorPipeline } from '../../lib/pipelines/update_pipeline';
@ -32,7 +34,10 @@ import { updateConnectorPipeline } from '../../lib/pipelines/update_pipeline';
import { RouteDependencies } from '../../plugin';
import { createError } from '../../utils/create_error';
import { elasticsearchErrorHandler } from '../../utils/elasticsearch_error_handler';
import { isAccessControlDisabledException } from '../../utils/identify_exceptions';
import {
isAccessControlDisabledException,
isExpensiveQueriesNotAllowedException,
} from '../../utils/identify_exceptions';
export function registerConnectorRoutes({ router, log }: RouteDependencies) {
router.post(
@ -467,4 +472,71 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) {
return result ? response.ok({ body: result }) : response.conflict();
})
);
router.get(
{
path: '/internal/enterprise_search/connectors',
validate: {
query: schema.object({
fetchCrawlersOnly: schema.maybe(schema.boolean()),
from: schema.number({ defaultValue: 0, min: 0 }),
searchQuery: schema.string({ defaultValue: '' }),
size: schema.number({ defaultValue: 10, min: 0 }),
}),
},
},
elasticsearchErrorHandler(log, async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
const { fetchCrawlersOnly, from, size, searchQuery } = request.query;
let connectorResult;
let connectorCountResult;
try {
connectorResult = await fetchConnectors(
client.asCurrentUser,
undefined,
fetchCrawlersOnly,
searchQuery
);
const indicesSlice = connectorResult
.reduce((acc: string[], connector) => {
if (connector.index_name) {
acc.push(connector.index_name);
}
return acc;
}, [])
.slice(from, from + size);
connectorCountResult = await fetchIndexCounts(client, indicesSlice);
} catch (error) {
if (isExpensiveQueriesNotAllowedException(error)) {
return createError({
errorCode: ErrorCode.EXPENSIVE_QUERY_NOT_ALLOWED_ERROR,
message: i18n.translate(
'xpack.enterpriseSearch.server.routes.connectors.expensive_query_not_allowed_error',
{
defaultMessage:
'Expensive search queries not allowed. "search.allow_expensive_queries" is set to false ',
}
),
response,
statusCode: 400,
});
}
throw error;
}
return response.ok({
body: {
connectors: connectorResult.slice(from, from + size),
counts: connectorCountResult,
meta: {
page: {
from,
size,
total: connectorResult.length,
},
},
},
});
})
);
}

View file

@ -11,6 +11,10 @@ export interface ElasticsearchResponseError {
meta?: {
body?: {
error?: {
caused_by?: {
reason?: string;
type?: string;
};
type: string;
};
};
@ -56,3 +60,10 @@ export const isMissingAliasException = (error: ElasticsearchResponseError) =>
export const isAccessControlDisabledException = (error: Error) => {
return error.message === ErrorCode.ACCESS_CONTROL_DISABLED;
};
export const isExpensiveQueriesNotAllowedException = (error: ElasticsearchResponseError) => {
return (
error.meta?.statusCode === 400 &&
error.meta?.body?.error?.caused_by?.reason?.includes('search.allow_expensive_queries')
);
};

View file

@ -13133,8 +13133,6 @@
"xpack.enterpriseSearch.content.indices.pipelines.successToastDeleteMlPipeline.title": "Pipeline d'inférence de Machine Learning \"{pipelineName}\" supprimé",
"xpack.enterpriseSearch.content.indices.pipelines.successToastDetachMlPipeline.title": "Pipeline d'inférence de Machine Learning détaché de \"{pipelineName}\"",
"xpack.enterpriseSearch.content.indices.pipelines.tabs.jsonConfigurations.unmanaged.description": "Modifier ce pipeline à partir de {ingestPipelines} dans Gestion de la Suite",
"xpack.enterpriseSearch.content.indices.selectConnector.description.selfManaged.text": "Déployez des connecteurs sur votre propre infrastructure en tant que {connectorsClient}. Vous pouvez également personnaliser les clients de connecteurs existants ou créez le vôtre à l'aide de notre infrastructure de connecteur. {learnMore}",
"xpack.enterpriseSearch.content.indices.selectConnector.description.textcloud": "{native} sont disponibles directement dans les déploiements Elastic Cloud. Aucune infrastructure supplémentaire n'est requise. {learnMore}",
"xpack.enterpriseSearch.content.new_index.connectorTitleWithServiceType": "Nouvel index de recherche {name}",
"xpack.enterpriseSearch.content.newIndex.newSearchIndexTemplate.alreadyExists.error": "Un index portant le nom {indexName} existe déjà",
"xpack.enterpriseSearch.content.newIndex.newSearchIndexTemplate.formDescription": "Cet index contiendra le contenu de la source de données et il est optimisé avec les mappings de champ par défaut pour les expériences de recherche correspondantes. Donnez un nom unique à votre index et définissez éventuellement un {language_analyzer} par défaut pour lindex.",
@ -14672,16 +14670,12 @@
"xpack.enterpriseSearch.content.indices.pipelines.textExpansionStartError.title": "Erreur lors du démarrage du déploiement de ELSER v2",
"xpack.enterpriseSearch.content.indices.searchIndex.convertConnector.buttonLabel": "Convertir un connecteur",
"xpack.enterpriseSearch.content.indices.selectConnector.allConnectorsLabel": "Tous les connecteurs",
"xpack.enterpriseSearch.content.indices.selectConnector.callout.description.connectorsClient": "clients de connecteurs",
"xpack.enterpriseSearch.content.indices.selectConnector.callout.description.native": "Connecteurs natifs",
"xpack.enterpriseSearch.content.indices.selectConnector.callout.title": "Connecteurs Elastic",
"xpack.enterpriseSearch.content.indices.selectConnector.connectorCheckable.documentationLinkLabel": "Documentation",
"xpack.enterpriseSearch.content.indices.selectConnector.connectorCheckable.techPreviewLabel": "Préversion technique",
"xpack.enterpriseSearch.content.indices.selectConnector.description": "Choisissez quelle source de données tierce vous souhaitez synchroniser avec Elastic.",
"xpack.enterpriseSearch.content.indices.selectConnector.nativeLabel": "Connecteurs natifs",
"xpack.enterpriseSearch.content.indices.selectConnector.search.ariaLabel": "Rechercher parmi les connecteurs",
"xpack.enterpriseSearch.content.indices.selectConnector.searchPlaceholder": "Recherche",
"xpack.enterpriseSearch.content.indices.selectConnector.showNonGALabel": "Afficher les connecteurs bêta et de préversion technique",
"xpack.enterpriseSearch.content.indices.selectConnector.title": "Sélectionner un connecteur",
"xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.footer.attach": "Attacher",
"xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.footer.create": "Créer un pipeline",

View file

@ -13146,8 +13146,6 @@
"xpack.enterpriseSearch.content.indices.pipelines.successToastDeleteMlPipeline.title": "機械学習推論パイプライン\"{pipelineName}\"を削除しました",
"xpack.enterpriseSearch.content.indices.pipelines.successToastDetachMlPipeline.title": "機械学習推論パイプラインを\"{pipelineName}\"からデタッチしました",
"xpack.enterpriseSearch.content.indices.pipelines.tabs.jsonConfigurations.unmanaged.description": "スタック管理で{ingestPipelines}からこのパイプラインを編集",
"xpack.enterpriseSearch.content.indices.selectConnector.description.selfManaged.text": "{connectorsClient}として、独自のインフラにコネクターをデプロイします。また、既存のコネクタークライアントをカスタマイズしたり、コネクターフレームワークを使用して独自のコネクタークライアントを構築したりすることもできます。{learnMore}",
"xpack.enterpriseSearch.content.indices.selectConnector.description.textcloud": "{native}は直接Elastic Cloudデプロイ内で使用できます。追加のインフラストラクチャーは必要ありません。{learnMore}",
"xpack.enterpriseSearch.content.new_index.connectorTitleWithServiceType": "新しい{name}検索インデックス",
"xpack.enterpriseSearch.content.newIndex.newSearchIndexTemplate.alreadyExists.error": "名前{indexName}のインデックスがすでに存在します",
"xpack.enterpriseSearch.content.newIndex.newSearchIndexTemplate.formDescription": "このインデックスには、データソースコンテンツが格納されます。また、デフォルトフィールドマッピングで最適化され、関連する検索エクスペリエンスを実現します。一意のインデックス名を指定し、任意でインデックスのデフォルト{language_analyzer}を設定します。",
@ -14685,16 +14683,12 @@
"xpack.enterpriseSearch.content.indices.pipelines.textExpansionStartError.title": "ELSER v2デプロイの起動エラー",
"xpack.enterpriseSearch.content.indices.searchIndex.convertConnector.buttonLabel": "コネクターを変換",
"xpack.enterpriseSearch.content.indices.selectConnector.allConnectorsLabel": "すべてのコネクター",
"xpack.enterpriseSearch.content.indices.selectConnector.callout.description.connectorsClient": "コネクタークライアント",
"xpack.enterpriseSearch.content.indices.selectConnector.callout.description.native": "ネイティブコネクター",
"xpack.enterpriseSearch.content.indices.selectConnector.callout.title": "Elasticコネクター",
"xpack.enterpriseSearch.content.indices.selectConnector.connectorCheckable.documentationLinkLabel": "ドキュメント",
"xpack.enterpriseSearch.content.indices.selectConnector.connectorCheckable.techPreviewLabel": "テクニカルプレビュー",
"xpack.enterpriseSearch.content.indices.selectConnector.description": "Elasticと同期するサードパーティのデータソースを選択します。",
"xpack.enterpriseSearch.content.indices.selectConnector.nativeLabel": "ネイティブコネクター",
"xpack.enterpriseSearch.content.indices.selectConnector.search.ariaLabel": "コネクターを使用して検索",
"xpack.enterpriseSearch.content.indices.selectConnector.searchPlaceholder": "検索",
"xpack.enterpriseSearch.content.indices.selectConnector.showNonGALabel": "ベータ版およびテクニカルプレビュー版コネクターを表示",
"xpack.enterpriseSearch.content.indices.selectConnector.title": "コネクターを選択",
"xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.footer.attach": "接続",
"xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.footer.create": "パイプラインの作成",

View file

@ -13146,8 +13146,6 @@
"xpack.enterpriseSearch.content.indices.pipelines.successToastDeleteMlPipeline.title": "已删除 Machine Learning 推理管道“{pipelineName}”",
"xpack.enterpriseSearch.content.indices.pipelines.successToastDetachMlPipeline.title": "已从“{pipelineName}”中分离 Machine Learning 推理管道",
"xpack.enterpriseSearch.content.indices.pipelines.tabs.jsonConfigurations.unmanaged.description": "从 Stack Management 中的 {ingestPipelines} 编辑此管道",
"xpack.enterpriseSearch.content.indices.selectConnector.description.selfManaged.text": "在您自己的基础设施上将连接器部署为 {connectorsClient}。还可以定制现有连接器客户端,或使用我们的连接器框架构建自己的客户端。{learnMore}",
"xpack.enterpriseSearch.content.indices.selectConnector.description.textcloud": "{native} 可直接用在 Elastic Cloud 部署中。无需额外的基础设施。{learnMore}",
"xpack.enterpriseSearch.content.new_index.connectorTitleWithServiceType": "新的 {name} 搜索索引",
"xpack.enterpriseSearch.content.newIndex.newSearchIndexTemplate.alreadyExists.error": "名为 {indexName} 的索引已存在",
"xpack.enterpriseSearch.content.newIndex.newSearchIndexTemplate.formDescription": "此索引将存放您的数据源内容,并通过默认字段映射进行优化,以提供相关搜索体验。提供唯一的索引名称,并为索引设置默认的 {language_analyzer}(可选)。",
@ -14685,16 +14683,12 @@
"xpack.enterpriseSearch.content.indices.pipelines.textExpansionStartError.title": "启动 ELSER 部署时出错",
"xpack.enterpriseSearch.content.indices.searchIndex.convertConnector.buttonLabel": "转换连接器",
"xpack.enterpriseSearch.content.indices.selectConnector.allConnectorsLabel": "所有连接器",
"xpack.enterpriseSearch.content.indices.selectConnector.callout.description.connectorsClient": "连接器客户端",
"xpack.enterpriseSearch.content.indices.selectConnector.callout.description.native": "本机连接器",
"xpack.enterpriseSearch.content.indices.selectConnector.callout.title": "Elastic 连接器",
"xpack.enterpriseSearch.content.indices.selectConnector.connectorCheckable.documentationLinkLabel": "文档",
"xpack.enterpriseSearch.content.indices.selectConnector.connectorCheckable.techPreviewLabel": "技术预览",
"xpack.enterpriseSearch.content.indices.selectConnector.description": "选择要将哪些第三方源同步到 Elastic。",
"xpack.enterpriseSearch.content.indices.selectConnector.nativeLabel": "本机连接器",
"xpack.enterpriseSearch.content.indices.selectConnector.search.ariaLabel": "通过连接器搜索",
"xpack.enterpriseSearch.content.indices.selectConnector.searchPlaceholder": "搜索",
"xpack.enterpriseSearch.content.indices.selectConnector.showNonGALabel": "显示公测版和技术预览连接器",
"xpack.enterpriseSearch.content.indices.selectConnector.title": "选择连接器",
"xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.footer.attach": "附加",
"xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.footer.create": "创建管道",