mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
2af7030b60
commit
45885a79a0
26 changed files with 1278 additions and 265 deletions
|
@ -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',
|
||||
},
|
||||
},
|
||||
/**
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 [];
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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
|
||||
>;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 } },
|
||||
],
|
||||
}),
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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';
|
||||
}
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
? [
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 l’index.",
|
||||
|
@ -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",
|
||||
|
|
|
@ -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": "パイプラインの作成",
|
||||
|
|
|
@ -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": "创建管道",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue