[Enterprise Search] Add sync now button for connectors (#136941)

This commit is contained in:
Sander Philipse 2022-07-22 19:37:42 +02:00 committed by GitHub
parent 0b8b66f73f
commit b12d58cd55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1017 additions and 273 deletions

View file

@ -38,14 +38,19 @@ export interface ConnectorIndex extends ElasticsearchIndex {
export interface CrawlerIndex extends ElasticsearchIndex {
crawler: Crawler;
}
export interface ElasticsearchIndexWithIngestion extends ElasticsearchIndex {
connector?: Connector;
crawler?: Crawler;
export interface ConnectorIndex extends ElasticsearchIndex {
connector: Connector;
}
export interface ElasticsearchIndexWithPrivileges extends ElasticsearchIndex {
alias: boolean;
privileges: {
read: boolean;
manage: boolean;
read: boolean;
};
}
export interface CrawlerIndex extends ElasticsearchIndex {
crawler: Crawler;
}
export type ElasticsearchIndexWithIngestion = ElasticsearchIndex | ConnectorIndex | CrawlerIndex;

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SyncStatus, ConnectorStatus } from '../../../../common/types/connectors';
import {
ApiViewIndex,
ConnectorViewIndex,
CrawlerViewIndex,
IngestionMethod,
IngestionStatus,
} from '../types';
export const apiIndex: ApiViewIndex = {
ingestionMethod: IngestionMethod.API,
ingestionStatus: IngestionStatus.CONNECTED,
lastUpdated: null,
name: 'api',
total: {
docs: {
count: 1,
deleted: 0,
},
store: { size_in_bytes: '8024' },
},
};
export const connectorIndex: ConnectorViewIndex = {
connector: {
api_key_id: null,
configuration: {},
id: '2',
index_name: 'connector',
last_seen: null,
last_sync_error: null,
last_sync_status: SyncStatus.COMPLETED,
last_synced: null,
scheduling: {
enabled: false,
interval: '',
},
service_type: null,
status: ConnectorStatus.CONFIGURED,
sync_now: false,
},
ingestionMethod: IngestionMethod.CONNECTOR,
ingestionStatus: IngestionStatus.INCOMPLETE,
lastUpdated: 'never',
name: 'connector',
total: {
docs: {
count: 1,
deleted: 0,
},
store: { size_in_bytes: '8024' },
},
};
export const crawlerIndex: CrawlerViewIndex = {
crawler: {
id: '3',
index_name: 'crawler',
},
ingestionMethod: IngestionMethod.CRAWLER,
ingestionStatus: IngestionStatus.INCOMPLETE,
lastUpdated: null,
name: 'crawler',
total: {
docs: {
count: 1,
deleted: 0,
},
store: { size_in_bytes: '8024' },
},
};
export const elasticsearchViewIndices = [apiIndex, connectorIndex, crawlerIndex];

View file

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

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface StartSyncArgs {
connectorId: string;
}
export const startSync = async ({ connectorId }: StartSyncArgs) => {
const route = `/internal/enterprise_search/connectors/${connectorId}/start_sync`;
return await HttpLogic.values.http.post(route);
};
export const StartSyncApiLogic = createApiLogic(['start_sync_api_logic'], startSync);

View file

@ -15,6 +15,8 @@ import {
EuiContextMenuPanel,
EuiContextMenuItem,
EuiText,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -23,62 +25,82 @@ import { APP_SEARCH_PLUGIN } from '../../../../../../../common/constants';
import { ENGINE_CREATION_PATH } from '../../../../../app_search/routes';
import { KibanaLogic } from '../../../../../shared/kibana';
import { IngestionMethod } from '../../../../types';
import { IndexViewLogic } from '../../index_view_logic';
import { HeaderActionsLogic } from './header_actions.logic';
import { SyncButton } from './sync_button';
const SearchEnginesPopover: React.FC = () => {
const { isSearchEnginesPopoverOpen } = useValues(HeaderActionsLogic);
const { toggleSearchEnginesPopover } = useActions(HeaderActionsLogic);
const { ingestionMethod } = useValues(IndexViewLogic);
return (
<EuiPopover
isOpen={isSearchEnginesPopoverOpen}
closePopover={toggleSearchEnginesPopover}
button={
<EuiButton iconSide="right" iconType="arrowDown" onClick={toggleSearchEnginesPopover}>
{i18n.translate('xpack.enterpriseSearch.content.index.searchEngines.label', {
defaultMessage: 'Search Engines',
})}
</EuiButton>
}
>
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem
icon="eye"
onClick={() => {
KibanaLogic.values.navigateToUrl(APP_SEARCH_PLUGIN.URL, {
shouldNotCreateHref: true,
});
}}
>
<EuiText>
<p>
{i18n.translate('xpack.enterpriseSearch.content.index.searchEngines.viewEngines', {
defaultMessage: 'View App Search engines',
})}
</p>
</EuiText>
</EuiContextMenuItem>,
<EuiContextMenuItem
icon="plusInCircle"
onClick={() => {
KibanaLogic.values.navigateToUrl(APP_SEARCH_PLUGIN.URL + ENGINE_CREATION_PATH, {
shouldNotCreateHref: true,
});
}}
>
<EuiText>
<p>
{i18n.translate('xpack.enterpriseSearch.content.index.searchEngines.createEngine', {
defaultMessage: 'Create a new App Search engine',
})}
</p>
</EuiText>
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiPopover
isOpen={isSearchEnginesPopoverOpen}
closePopover={toggleSearchEnginesPopover}
button={
<EuiButton iconSide="right" iconType="arrowDown" onClick={toggleSearchEnginesPopover}>
{i18n.translate('xpack.enterpriseSearch.content.index.searchEngines.label', {
defaultMessage: 'Search Engines',
})}
</EuiButton>
}
>
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem
icon="eye"
onClick={() => {
KibanaLogic.values.navigateToUrl(APP_SEARCH_PLUGIN.URL, {
shouldNotCreateHref: true,
});
}}
>
<EuiText>
<p>
{i18n.translate(
'xpack.enterpriseSearch.content.index.searchEngines.viewEngines',
{
defaultMessage: 'View App Search engines',
}
)}
</p>
</EuiText>
</EuiContextMenuItem>,
<EuiContextMenuItem
icon="plusInCircle"
onClick={() => {
KibanaLogic.values.navigateToUrl(APP_SEARCH_PLUGIN.URL + ENGINE_CREATION_PATH, {
shouldNotCreateHref: true,
});
}}
>
<EuiText>
<p>
{i18n.translate(
'xpack.enterpriseSearch.content.index.searchEngines.createEngine',
{
defaultMessage: 'Create a new App Search engine',
}
)}
</p>
</EuiText>
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
</EuiFlexItem>
{ingestionMethod === IngestionMethod.CONNECTOR && (
<EuiFlexItem>
<SyncButton />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,53 @@
/*
* 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, useActions } from 'kea';
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { IngestionStatus } from '../../../../types';
import { IndexViewLogic } from '../../index_view_logic';
export const SyncButton: React.FC = () => {
const { ingestionStatus, isSyncing, isWaitingForSync } = useValues(IndexViewLogic);
const { startSync } = useActions(IndexViewLogic);
const getSyncButtonText = () => {
if (isWaitingForSync) {
return i18n.translate(
'xpack.enterpriseSearch.content.index.syncButton.waitingForSync.label',
{
defaultMessage: 'Waiting for sync',
}
);
}
if (isSyncing && ingestionStatus !== IngestionStatus.ERROR) {
return i18n.translate('xpack.enterpriseSearch.content.index.syncButton.syncing.label', {
defaultMessage: 'Syncing',
});
}
return i18n.translate('xpack.enterpriseSearch.content.index.syncButton.label', {
defaultMessage: 'Sync',
});
};
return (
<EuiButton
onClick={startSync}
fill
disabled={ingestionStatus === IngestionStatus.INCOMPLETE}
isLoading={
// If there's an error, the ingestion status may not be accurate and we may need to be able to trigger a sync
(isSyncing && !(ingestionStatus === IngestionStatus.ERROR)) || isWaitingForSync
}
>
{getSyncButtonText()}
</EuiButton>
);
};

View file

@ -28,6 +28,7 @@ import { EuiButtonTo } from '../../../../shared/react_router_helpers';
import { GenerateConnectorApiKeyApiLogic } from '../../../api/connector_package/generate_connector_api_key_api_logic';
import { FetchIndexApiLogic } from '../../../api/index/fetch_index_api_logic';
import { SEARCH_INDEX_TAB_PATH } from '../../../routes';
import { isConnectorIndex } from '../../../utils/indices';
import { ApiKey } from '../../api_key/api_key';
import { IndexNameLogic } from '../index_name_logic';
@ -41,22 +42,22 @@ export const ConnectorConfiguration: React.FC = () => {
const { data: apiKeyData } = useValues(GenerateConnectorApiKeyApiLogic);
const { data: indexData } = useValues(FetchIndexApiLogic);
const { indexName } = useValues(IndexNameLogic);
const indexId = indexData?.connector?.id ?? '';
if (!isConnectorIndex(indexData)) {
return <></>;
}
const indexId = indexData.connector.id ?? '';
const hasApiKey = !!(indexData?.connector?.api_key_id ?? apiKeyData);
const hasApiKey = !!(indexData.connector.api_key_id ?? apiKeyData);
const ConnectorConfig: React.FC = () =>
indexData?.connector ? (
<ConnectorConfigurationConfig
apiKey={apiKeyData?.encoded}
configuration={indexData.connector.configuration}
connectorId={indexData.connector.id}
indexId={indexId}
indexName={indexName}
/>
) : (
<></>
);
const ConnectorConfig: React.FC = () => (
<ConnectorConfigurationConfig
apiKey={apiKeyData?.encoded}
configuration={indexData.connector.configuration}
connectorId={indexData.connector.id}
indexId={indexId}
indexName={indexName}
/>
);
const ScheduleStep: React.FC = () => (
<EuiFlexGroup direction="column">
@ -145,7 +146,7 @@ export const ConnectorConfiguration: React.FC = () => {
children: (
<ApiKeyConfig
indexName={indexName}
hasApiKey={!!indexData?.connector?.api_key_id}
hasApiKey={!!indexData.connector.api_key_id}
/>
),
status: hasApiKey ? 'complete' : 'incomplete',
@ -160,7 +161,7 @@ export const ConnectorConfiguration: React.FC = () => {
{
children: <ConnectorPackage />,
status:
!indexData?.connector?.status ||
!indexData.connector.status ||
indexData.connector.status === ConnectorStatus.CREATED
? 'incomplete'
: 'complete',
@ -175,7 +176,6 @@ export const ConnectorConfiguration: React.FC = () => {
{
children: <ConnectorConfig />,
status:
indexData?.connector?.status &&
indexData.connector.status === ConnectorStatus.CONNECTED
? 'complete'
: 'incomplete',
@ -189,7 +189,7 @@ export const ConnectorConfiguration: React.FC = () => {
},
{
children: <ScheduleStep />,
status: indexData?.connector?.scheduling.enabled ? 'complete' : 'incomplete',
status: indexData.connector.scheduling.enabled ? 'complete' : 'incomplete',
title: i18n.translate(
'xpack.enterpriseSearch.content.indices.configurationConnector.steps.schedule.title',
{

View file

@ -68,7 +68,7 @@ export const ConnectorConfigurationConfig: React.FC<ConnectorConfigurationConfig
api_key: "${apiKey}"
`
: ''
}connector_package_id: "${connectorId}"
}connector_id: "${connectorId}"
`}
</EuiCodeBlock>
);

View file

@ -19,14 +19,18 @@ import {
EuiButton,
EuiButtonEmpty,
} from '@elastic/eui';
import { CronEditor, Frequency } from '@kbn/es-ui-shared-plugin/public';
import { i18n } from '@kbn/i18n';
import { Status } from '../../../../../../common/types/api';
import { ConnectorIndex } from '../../../../../../common/types/indices';
import { UnsavedChangesPrompt } from '../../../../shared/unsaved_changes_prompt';
import { UpdateConnectorSchedulingApiLogic } from '../../../api/connector_package/update_connector_scheduling_api_logic';
import { FetchIndexApiLogic } from '../../../api/index/fetch_index_api_logic';
import { isConnectorIndex } from '../../../utils/indices';
import { ConnectorSchedulingLogic } from './connector_scheduling_logic';
export const ConnectorSchedulingComponent: React.FC = () => {
@ -36,7 +40,8 @@ export const ConnectorSchedulingComponent: React.FC = () => {
const { hasChanges } = useValues(ConnectorSchedulingLogic);
const { setHasChanges } = useActions(ConnectorSchedulingLogic);
const schedulingInput = data?.connector?.scheduling;
// Need to do this ugly casting because we can't check this after the below typecheck, because useState can't be used after an if
const schedulingInput = (data as ConnectorIndex)?.connector?.scheduling;
const [scheduling, setScheduling] = useState(schedulingInput);
const [fieldToPreferredValueMap, setFieldToPreferredValueMap] = useState({});
const [simpleCron, setSimpleCron] = useState<{
@ -47,7 +52,11 @@ export const ConnectorSchedulingComponent: React.FC = () => {
frequency: schedulingInput?.interval ? cronToFrequency(schedulingInput.interval) : 'HOUR',
});
const editor = scheduling && (
if (!isConnectorIndex(data)) {
return <></>;
}
const editor = (
<CronEditor
fieldToPreferredValueMap={fieldToPreferredValueMap}
cronExpression={simpleCron.expression}
@ -68,7 +77,7 @@ export const ConnectorSchedulingComponent: React.FC = () => {
/>
);
return scheduling ? (
return (
<>
<UnsavedChangesPrompt
hasUnsavedChanges={hasChanges}
@ -150,8 +159,6 @@ export const ConnectorSchedulingComponent: React.FC = () => {
</EuiFlexGroup>
</EuiPanel>
</>
) : (
<></>
);
};

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LogicMounter, mockFlashMessageHelpers } from '../../../__mocks__/kea_logic';
import { apiIndex, connectorIndex } from '../../__mocks__/view_index.mock';
import { nextTick } from '@kbn/test-jest-helpers';
import { HttpError } from '../../../../../common/types/api';
import { SyncStatus } from '../../../../../common/types/connectors';
import { StartSyncApiLogic } from '../../api/connector_package/start_sync_api_logic';
import { FetchIndexApiLogic } from '../../api/index/fetch_index_api_logic';
import { IngestionMethod, IngestionStatus } from '../../types';
import { indexToViewIndex } from '../../utils/indices';
import { IndexViewLogic } from './index_view_logic';
const DEFAULT_VALUES = {
data: undefined,
index: undefined,
ingestionMethod: IngestionMethod.API,
ingestionStatus: IngestionStatus.CONNECTED,
isSyncing: false,
isWaitingForSync: false,
lastUpdated: null,
localSyncNowValue: false,
syncStatus: undefined,
};
const CONNECTOR_VALUES = {
...DEFAULT_VALUES,
data: connectorIndex,
index: indexToViewIndex(connectorIndex),
ingestionMethod: IngestionMethod.CONNECTOR,
ingestionStatus: IngestionStatus.INCOMPLETE,
lastUpdated: 'never',
};
describe('IndexViewLogic', () => {
const { mount: apiLogicMount } = new LogicMounter(StartSyncApiLogic);
const { mount: fetchIndexMount } = new LogicMounter(FetchIndexApiLogic);
const { mount } = new LogicMounter(IndexViewLogic);
beforeEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
apiLogicMount();
fetchIndexMount();
mount();
});
it('has expected default values', () => {
expect(IndexViewLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('fetchIndex.apiSuccess', () => {
it('should update values', () => {
FetchIndexApiLogic.actions.apiSuccess({
...connectorIndex,
connector: { ...connectorIndex.connector!, sync_now: true },
});
expect(IndexViewLogic.values).toEqual({
...CONNECTOR_VALUES,
data: {
...CONNECTOR_VALUES.data,
connector: { ...CONNECTOR_VALUES.data.connector, sync_now: true },
},
index: {
...CONNECTOR_VALUES.index,
connector: { ...CONNECTOR_VALUES.index.connector, sync_now: true },
},
isWaitingForSync: true,
syncStatus: SyncStatus.COMPLETED,
});
});
it('should update values with no connector', () => {
FetchIndexApiLogic.actions.apiSuccess(apiIndex);
expect(IndexViewLogic.values).toEqual({
...DEFAULT_VALUES,
data: apiIndex,
index: apiIndex,
});
});
});
describe('startSync', () => {
it('should call makeRequest', async () => {
FetchIndexApiLogic.actions.apiSuccess(connectorIndex);
IndexViewLogic.actions.makeRequest = jest.fn();
IndexViewLogic.actions.startSync();
await nextTick();
expect(IndexViewLogic.actions.makeRequest).toHaveBeenCalledWith({
connectorId: '2',
});
expect(IndexViewLogic.values).toEqual({
...CONNECTOR_VALUES,
syncStatus: SyncStatus.COMPLETED,
});
});
});
describe('apiSuccess', () => {
it('should set localSyncNow to true', async () => {
FetchIndexApiLogic.actions.apiSuccess(connectorIndex);
StartSyncApiLogic.actions.apiSuccess({});
expect(IndexViewLogic.values).toEqual({
...CONNECTOR_VALUES,
isWaitingForSync: true,
localSyncNowValue: true,
syncStatus: SyncStatus.COMPLETED,
});
});
});
});
describe('listeners', () => {
it('calls clearFlashMessages on makeRequest', () => {
IndexViewLogic.actions.makeRequest({ connectorId: 'connectorId' });
expect(mockFlashMessageHelpers.clearFlashMessages).toHaveBeenCalledTimes(1);
});
it('calls flashAPIErrors on apiError', () => {
IndexViewLogic.actions.apiError({} as HttpError);
expect(mockFlashMessageHelpers.flashAPIErrors).toHaveBeenCalledTimes(1);
expect(mockFlashMessageHelpers.flashAPIErrors).toHaveBeenCalledWith({});
});
});
});

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { i18n } from '@kbn/i18n';
import { SyncStatus } from '../../../../../common/types/connectors';
import { Actions } from '../../../shared/api_logic/create_api_logic';
import {
flashAPIErrors,
clearFlashMessages,
flashSuccessToast,
} from '../../../shared/flash_messages';
import { StartSyncApiLogic, StartSyncArgs } from '../../api/connector_package/start_sync_api_logic';
import {
FetchIndexApiLogic,
FetchIndexApiParams,
FetchIndexApiResponse,
} from '../../api/index/fetch_index_api_logic';
import { ElasticsearchViewIndex, IngestionMethod, IngestionStatus } from '../../types';
import {
getIngestionMethod,
getIngestionStatus,
getLastUpdated,
indexToViewIndex,
isConnectorIndex,
} from '../../utils/indices';
export type IndicesActions = Pick<
Actions<StartSyncArgs, {}>,
'makeRequest' | 'apiSuccess' | 'apiError'
> & {
fetchIndexSuccess: Actions<FetchIndexApiParams, FetchIndexApiResponse>['apiSuccess'];
startSync(): void;
};
export interface IndicesValues {
data: typeof FetchIndexApiLogic.values.data;
index: ElasticsearchViewIndex | undefined;
ingestionMethod: IngestionMethod;
ingestionStatus: IngestionStatus;
isSyncing: boolean;
isWaitingForSync: boolean;
lastUpdated: string | null;
localSyncNowValue: boolean; // holds local value after update so UI updates correctly
syncStatus: SyncStatus;
}
export const IndexViewLogic = kea<MakeLogicType<IndicesValues, IndicesActions>>({
actions: {
startSync: true,
},
connect: {
actions: [StartSyncApiLogic, ['makeRequest', 'apiSuccess', 'apiError']],
values: [FetchIndexApiLogic, ['data']],
},
listeners: ({ actions, values }) => ({
apiError: (e) => flashAPIErrors(e),
apiSuccess: () => {
flashSuccessToast(
i18n.translate('xpack.enterpriseSearch.content.searchIndex.index.syncSuccess.message', {
defaultMessage: 'Successfully scheduled a sync, waiting for a connector to pick it up',
})
);
},
makeRequest: () => clearFlashMessages(),
startSync: () => {
if (isConnectorIndex(values.data)) {
actions.makeRequest({ connectorId: values.data?.connector?.id });
}
},
}),
path: ['enterprise_search', 'content', 'view_index_logic'],
reducers: {
localSyncNowValue: [
false,
{
apiSuccess: () => true,
fetchIndexSuccess: (_, index) =>
isConnectorIndex(index) ? index.connector.sync_now : false,
},
],
},
selectors: ({ selectors }) => ({
index: [() => [selectors.data], (data) => (data ? indexToViewIndex(data) : undefined)],
ingestionMethod: [() => [selectors.data], (data) => getIngestionMethod(data)],
ingestionStatus: [() => [selectors.data], (data) => getIngestionStatus(data)],
isSyncing: [
() => [selectors.syncStatus],
(syncStatus) => syncStatus === SyncStatus.IN_PROGRESS,
],
isWaitingForSync: [
() => [selectors.data, selectors.localSyncNowValue],
(data, localSyncNowValue) => data?.connector?.sync_now || localSyncNowValue,
],
lastUpdated: [() => [selectors.data], (data) => getLastUpdated(data)],
syncStatus: [() => [selectors.data], (data) => data?.connector?.last_sync_status],
}),
});

View file

@ -13,6 +13,8 @@ import { EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isApiIndex, isConnectorIndex, isCrawlerIndex } from '../../utils/indices';
import { CrawlDetailsFlyout } from './crawler/crawl_details_flyout/crawl_details_flyout';
import { CrawlRequestsPanel } from './crawler/crawl_requests_panel/crawl_requests_panel';
import { CrawlerTotalStats } from './crawler_total_stats';
@ -23,19 +25,15 @@ import { TotalStats } from './total_stats';
export const SearchIndexOverview: React.FC = () => {
const { indexData } = useValues(OverviewLogic);
const isCrawler = typeof indexData?.crawler !== 'undefined';
const isConnector = typeof indexData?.connector !== 'undefined';
const isApi = !(isCrawler || isConnector);
return (
<>
<EuiSpacer />
{isCrawler ? (
{isCrawlerIndex(indexData) ? (
<CrawlerTotalStats />
) : (
<TotalStats
ingestionType={
isConnector
isConnectorIndex(indexData)
? i18n.translate(
'xpack.enterpriseSearch.content.searchIndex.totalStats.connectorIngestionMethodLabel',
{
@ -51,13 +49,13 @@ export const SearchIndexOverview: React.FC = () => {
}
/>
)}
{isApi && (
{isApiIndex(indexData) && (
<>
<EuiSpacer />
<GenerateApiKeyPanel />
</>
)}
{isCrawler && (
{isCrawlerIndex(indexData) && (
<>
<EuiSpacer />
<CrawlRequestsPanel />

View file

@ -20,6 +20,7 @@ import { generateEncodedPath } from '../../../shared/encode_path_params';
import { KibanaLogic } from '../../../shared/kibana';
import { FetchIndexApiLogic } from '../../api/index/fetch_index_api_logic';
import { SEARCH_INDEX_PATH, SEARCH_INDEX_TAB_PATH } from '../../routes';
import { isConnectorIndex, isCrawlerIndex } from '../../utils/indices';
import { EnterpriseSearchContentPageTemplate } from '../layout/page_template';
import { baseBreadcrumbs } from '../search_indices';
@ -125,8 +126,8 @@ export const SearchIndex: React.FC = () => {
const tabs: EuiTabbedContentTab[] = [
...ALL_INDICES_TABS,
...(indexData?.connector ? CONNECTOR_TABS : []),
...(indexData?.crawler ? CRAWLER_TABS : []),
...(isConnectorIndex(indexData) ? CONNECTOR_TABS : []),
...(isCrawlerIndex(indexData) ? CRAWLER_TABS : []),
];
const selectedTab = tabs.find((tab) => tab.id === tabId);
@ -151,14 +152,14 @@ export const SearchIndex: React.FC = () => {
pageTitle: indexName,
rightSideItems: [
...headerActions,
...(indexData?.crawler ? [<CrawlerStatusIndicator />] : []),
...(isCrawlerIndex(indexData) ? [<CrawlerStatusIndicator />] : []),
],
}}
>
<>
{isCalloutVisible && <IndexCreatedCallout indexName={indexName} />}
<EuiTabbedContent tabs={tabs} selectedTab={selectedTab} onTabClick={onTabClick} />
{indexData?.crawler && <CrawlCustomSettingsFlyout />}
{isCrawlerIndex(indexData) && <CrawlCustomSettingsFlyout />}
</>
</EnterpriseSearchContentPageTemplate>
);

View file

@ -9,6 +9,8 @@ import { LogicMounter, mockFlashMessageHelpers } from '../../../__mocks__/kea_lo
import { indices } from '../../__mocks__/search_indices.mock';
import { connectorIndex, elasticsearchViewIndices } from '../../__mocks__/view_index.mock';
import moment from 'moment';
import { nextTick } from '@kbn/test-jest-helpers';
@ -20,7 +22,9 @@ import { DEFAULT_META } from '../../../shared/constants';
import { FetchIndicesAPILogic } from '../../api/index/fetch_indices_api_logic';
import { IndicesLogic, IngestionMethod, IngestionStatus, ViewSearchIndex } from './indices_logic';
import { IngestionStatus } from '../../types';
import { IndicesLogic } from './indices_logic';
const DEFAULT_VALUES = {
data: undefined,
@ -31,69 +35,6 @@ const DEFAULT_VALUES = {
status: Status.IDLE,
};
const apiIndex: ViewSearchIndex = {
ingestionMethod: IngestionMethod.API,
ingestionStatus: IngestionStatus.CONNECTED,
lastUpdated: null,
name: 'api',
total: {
docs: {
count: 1,
deleted: 0,
},
store: { size_in_bytes: '8024' },
},
};
const connectorIndex: ViewSearchIndex = {
connector: {
api_key_id: null,
configuration: {},
id: '2',
index_name: 'connector',
last_seen: null,
last_sync_error: null,
last_sync_status: SyncStatus.COMPLETED,
last_synced: null,
scheduling: {
enabled: false,
interval: '',
},
service_type: null,
status: ConnectorStatus.CONFIGURED,
sync_now: false,
},
ingestionMethod: IngestionMethod.CONNECTOR,
ingestionStatus: IngestionStatus.INCOMPLETE,
lastUpdated: 'never',
name: 'connector',
total: {
docs: {
count: 1,
deleted: 0,
},
store: { size_in_bytes: '8024' },
},
};
const crawlerIndex: ViewSearchIndex = {
crawler: {
id: '3',
index_name: 'crawler',
},
ingestionMethod: IngestionMethod.CRAWLER,
ingestionStatus: IngestionStatus.INCOMPLETE,
lastUpdated: null,
name: 'crawler',
total: {
docs: {
count: 1,
deleted: 0,
},
store: { size_in_bytes: '8024' },
},
};
const viewSearchIndices = [apiIndex, connectorIndex, crawlerIndex];
describe('IndicesLogic', () => {
const { mount: apiLogicMount } = new LogicMounter(FetchIndicesAPILogic);
const { mount } = new LogicMounter(IndicesLogic);
@ -150,7 +91,7 @@ describe('IndicesLogic', () => {
meta: newMeta,
},
hasNoIndices: false,
indices: viewSearchIndices,
indices: elasticsearchViewIndices,
isLoading: false,
meta: newMeta,
status: Status.SUCCESS,
@ -275,19 +216,19 @@ describe('IndicesLogic', () => {
it('updates when apiSuccess listener triggered', () => {
expect(IndicesLogic.values).toEqual(DEFAULT_VALUES);
IndicesLogic.actions.apiSuccess({
indices: viewSearchIndices,
indices: elasticsearchViewIndices,
isInitialRequest: true,
meta: DEFAULT_META,
});
expect(IndicesLogic.values).toEqual({
data: {
indices: viewSearchIndices,
indices: elasticsearchViewIndices,
isInitialRequest: true,
meta: DEFAULT_META,
},
hasNoIndices: false,
indices: viewSearchIndices,
indices: elasticsearchViewIndices,
isLoading: false,
meta: DEFAULT_META,
status: Status.SUCCESS,
@ -300,9 +241,9 @@ describe('IndicesLogic', () => {
IndicesLogic.actions.apiSuccess({
indices: [
{
...indices[1],
...connectorIndex,
connector: {
...indices[1].connector!,
...connectorIndex.connector!,
last_seen: lastSeen,
status: ConnectorStatus.CONNECTED,
},
@ -316,9 +257,9 @@ describe('IndicesLogic', () => {
data: {
indices: [
{
...indices[1],
...connectorIndex,
connector: {
...indices[1].connector!,
...connectorIndex.connector!,
last_seen: lastSeen,
status: ConnectorStatus.CONNECTED,
},
@ -349,8 +290,8 @@ describe('IndicesLogic', () => {
IndicesLogic.actions.apiSuccess({
indices: [
{
...indices[1],
connector: { ...indices[1].connector!, status: ConnectorStatus.CONNECTED },
...connectorIndex,
connector: { ...connectorIndex.connector, status: ConnectorStatus.CONNECTED },
},
],
isInitialRequest: true,
@ -361,8 +302,8 @@ describe('IndicesLogic', () => {
data: {
indices: [
{
...indices[1],
connector: { ...indices[1].connector!, status: ConnectorStatus.CONNECTED },
...connectorIndex,
connector: { ...connectorIndex.connector, status: ConnectorStatus.CONNECTED },
},
],
isInitialRequest: true,
@ -389,8 +330,8 @@ describe('IndicesLogic', () => {
IndicesLogic.actions.apiSuccess({
indices: [
{
...indices[1],
connector: { ...indices[1].connector!, status: ConnectorStatus.ERROR },
...connectorIndex,
connector: { ...connectorIndex.connector!, status: ConnectorStatus.ERROR },
},
],
isInitialRequest: true,
@ -401,8 +342,8 @@ describe('IndicesLogic', () => {
data: {
indices: [
{
...indices[1],
connector: { ...indices[1].connector!, status: ConnectorStatus.ERROR },
...connectorIndex,
connector: { ...connectorIndex.connector!, status: ConnectorStatus.ERROR },
},
],
isInitialRequest: true,
@ -426,9 +367,9 @@ describe('IndicesLogic', () => {
IndicesLogic.actions.apiSuccess({
indices: [
{
...indices[1],
...connectorIndex,
connector: {
...indices[1].connector!,
...connectorIndex.connector!,
last_sync_status: SyncStatus.ERROR,
status: ConnectorStatus.CONNECTED,
},
@ -442,9 +383,9 @@ describe('IndicesLogic', () => {
data: {
indices: [
{
...indices[1],
...connectorIndex,
connector: {
...indices[1].connector!,
...connectorIndex.connector!,
last_sync_status: SyncStatus.ERROR,
status: ConnectorStatus.CONNECTED,
},

View file

@ -6,72 +6,16 @@
*/
import { kea, MakeLogicType } from 'kea';
import moment from 'moment';
import { Meta } from '../../../../../common/types';
import { HttpError, Status } from '../../../../../common/types/api';
import { ConnectorStatus, SyncStatus } from '../../../../../common/types/connectors';
import { ElasticsearchIndexWithIngestion } from '../../../../../common/types/indices';
import { DEFAULT_META } from '../../../shared/constants';
import { flashAPIErrors, clearFlashMessages } from '../../../shared/flash_messages';
import { updateMetaPageIndex } from '../../../shared/table_pagination';
import { FetchIndicesAPILogic } from '../../api/index/fetch_indices_api_logic';
export const enum IngestionMethod {
CONNECTOR,
CRAWLER,
API,
}
export const enum IngestionStatus {
CONNECTED,
ERROR,
SYNC_ERROR,
INCOMPLETE,
}
export interface ViewSearchIndex extends ElasticsearchIndexWithIngestion {
ingestionMethod: IngestionMethod;
ingestionStatus: IngestionStatus;
lastUpdated: Date | 'never' | null;
}
function getIngestionMethod(index?: ElasticsearchIndexWithIngestion): IngestionMethod {
if (index?.connector) {
return IngestionMethod.CONNECTOR;
}
if (index?.crawler) {
return IngestionMethod.CRAWLER;
}
return IngestionMethod.API;
}
function getIngestionStatus(
index: ElasticsearchIndexWithIngestion,
ingestionMethod: IngestionMethod
): IngestionStatus {
if (ingestionMethod === IngestionMethod.API) {
return IngestionStatus.CONNECTED;
}
if (ingestionMethod === IngestionMethod.CONNECTOR) {
if (
index.connector?.last_seen &&
moment(index.connector.last_seen).isBefore(moment().subtract(30, 'minutes'))
) {
return IngestionStatus.ERROR;
}
if (index.connector?.last_sync_status === SyncStatus.ERROR) {
return IngestionStatus.SYNC_ERROR;
}
if (index.connector?.status === ConnectorStatus.CONNECTED) {
return IngestionStatus.CONNECTED;
}
if (index.connector?.status === ConnectorStatus.ERROR) {
return IngestionStatus.ERROR;
}
}
return IngestionStatus.INCOMPLETE;
}
import { ElasticsearchViewIndex } from '../../types';
import { indexToViewIndex } from '../../utils/indices';
export interface IndicesActions {
apiError(error: HttpError): HttpError;
@ -103,7 +47,7 @@ export interface IndicesActions {
export interface IndicesValues {
data: typeof FetchIndicesAPILogic.values.data;
hasNoIndices: boolean;
indices: ViewSearchIndex[];
indices: ElasticsearchViewIndex[];
isLoading: boolean;
meta: Meta;
status: typeof FetchIndicesAPILogic.values.status;
@ -149,15 +93,7 @@ export const IndicesLogic = kea<MakeLogicType<IndicesValues, IndicesActions>>({
],
indices: [
() => [selectors.data],
(data) =>
data?.indices
? data.indices.map((index: ElasticsearchIndexWithIngestion) => ({
...index,
ingestionMethod: getIngestionMethod(index),
ingestionStatus: getIngestionStatus(index, getIngestionMethod(index)),
lastUpdated: index.connector ? index.connector.last_synced ?? 'never' : null,
}))
: [],
(data) => (data?.indices ? data.indices.map(indexToViewIndex) : []),
],
isLoading: [
() => [selectors.status],

View file

@ -24,8 +24,8 @@ import { Meta } from '../../../../../common/types';
import { EuiLinkTo, EuiButtonIconTo } from '../../../shared/react_router_helpers';
import { convertMetaToPagination } from '../../../shared/table_pagination';
import { SEARCH_INDEX_PATH } from '../../routes';
import { ViewSearchIndex, IngestionMethod, IngestionStatus } from './indices_logic';
import { ElasticsearchViewIndex, IngestionMethod, IngestionStatus } from '../../types';
import { ingestionMethodToText } from '../../utils/indices';
const healthColorsMap = {
green: 'success',
@ -34,29 +34,7 @@ const healthColorsMap = {
yellow: 'warning',
};
function ingestionMethodToText(ingestionMethod: IngestionMethod) {
if (ingestionMethod === IngestionMethod.CONNECTOR) {
return i18n.translate(
'xpack.enterpriseSearch.content.searchIndices.ingestionMethod.connector.label',
{
defaultMessage: 'Connector',
}
);
}
if (ingestionMethod === IngestionMethod.CRAWLER) {
return i18n.translate(
'xpack.enterpriseSearch.content.searchIndices.ingestionMethod.crawler.label',
{
defaultMessage: 'Crawler',
}
);
}
return i18n.translate('xpack.enterpriseSearch.content.searchIndices.ingestionMethod.api.label', {
defaultMessage: 'API',
});
}
const columns: Array<EuiBasicTableColumn<ViewSearchIndex>> = [
const columns: Array<EuiBasicTableColumn<ElasticsearchViewIndex>> = [
{
field: 'name',
name: i18n.translate('xpack.enterpriseSearch.content.searchIndices.name.columnTitle', {
@ -207,10 +185,10 @@ const columns: Array<EuiBasicTableColumn<ViewSearchIndex>> = [
];
interface IndicesTableProps {
indices: ViewSearchIndex[];
indices: ElasticsearchViewIndex[];
isLoading: boolean;
meta: Meta;
onChange: (criteria: CriteriaWithPagination<ViewSearchIndex>) => void;
onChange: (criteria: CriteriaWithPagination<ElasticsearchViewIndex>) => void;
}
export const IndicesTable: React.FC<IndicesTableProps> = ({

View file

@ -5,6 +5,35 @@
* 2.0.
*/
import { ConnectorIndex, CrawlerIndex, ElasticsearchIndex } from '../../../common/types/indices';
export interface Crawler {
domains: [];
}
export const enum IngestionMethod {
CONNECTOR,
CRAWLER,
API,
}
export const enum IngestionStatus {
CONNECTED,
ERROR,
SYNC_ERROR,
INCOMPLETE,
}
interface ElasticsearchViewIndexExtension {
ingestionMethod: IngestionMethod;
ingestionStatus: IngestionStatus;
lastUpdated: string | 'never' | null; // date string
}
export type ConnectorViewIndex = ConnectorIndex & ElasticsearchViewIndexExtension;
export type CrawlerViewIndex = CrawlerIndex & ElasticsearchViewIndexExtension;
export type ApiViewIndex = ElasticsearchIndex & ElasticsearchViewIndexExtension;
export type ElasticsearchViewIndex = CrawlerViewIndex | ConnectorViewIndex | ApiViewIndex;

View file

@ -0,0 +1,119 @@
/*
* 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 { connectorIndex, crawlerIndex, apiIndex } from '../__mocks__/view_index.mock';
import moment from 'moment';
import { ConnectorStatus, SyncStatus } from '../../../../common/types/connectors';
import { IngestionMethod, IngestionStatus } from '../types';
import {
getIngestionMethod,
getIngestionStatus,
getLastUpdated,
indexToViewIndex,
} from './indices';
describe('Indices util functions', () => {
describe('getIngestionMethod', () => {
it('should return correct ingestion method for connector', () => {
expect(getIngestionMethod(connectorIndex)).toEqual(IngestionMethod.CONNECTOR);
});
it('should return correct ingestion method for crawler', () => {
expect(getIngestionMethod(crawlerIndex)).toEqual(IngestionMethod.CRAWLER);
});
it('should return correct ingestion method for API', () => {
expect(getIngestionMethod(apiIndex)).toEqual(IngestionMethod.API);
});
it('should return API for undefined index', () => {
expect(getIngestionMethod(undefined)).toEqual(IngestionMethod.API);
});
});
describe('getIngestionStatus', () => {
it('should return connected for API', () => {
expect(getIngestionStatus(apiIndex)).toEqual(IngestionStatus.CONNECTED);
});
it('should return connected for undefined', () => {
expect(getIngestionStatus(undefined)).toEqual(IngestionStatus.CONNECTED);
});
it('should return incomplete for incomplete connector', () => {
expect(getIngestionStatus(connectorIndex)).toEqual(IngestionStatus.INCOMPLETE);
});
it('should return connected for complete connector', () => {
expect(
getIngestionStatus({
...connectorIndex,
connector: { ...connectorIndex.connector, status: ConnectorStatus.CONNECTED },
})
).toEqual(IngestionStatus.CONNECTED);
});
it('should return error for connector that last checked in more than 30 minutes ago', () => {
const lastSeen = moment().subtract(31, 'minutes').format();
expect(
getIngestionStatus({
...connectorIndex,
connector: {
...connectorIndex.connector,
last_seen: lastSeen,
status: ConnectorStatus.CONNECTED,
},
})
).toEqual(IngestionStatus.ERROR);
});
it('should return sync error for complete connector with sync error', () => {
expect(
getIngestionStatus({
...connectorIndex,
connector: {
...connectorIndex.connector,
last_sync_status: SyncStatus.ERROR,
status: ConnectorStatus.NEEDS_CONFIGURATION,
},
})
).toEqual(IngestionStatus.SYNC_ERROR);
});
it('should return error for connector with error', () => {
expect(
getIngestionStatus({
...connectorIndex,
connector: {
...connectorIndex.connector,
last_sync_status: SyncStatus.COMPLETED,
status: ConnectorStatus.ERROR,
},
})
).toEqual(IngestionStatus.ERROR);
});
});
describe('getLastUpdated', () => {
it('should return never for connector with no last updated time', () => {
expect(getLastUpdated(connectorIndex)).toEqual('never');
});
it('should return last_synced for connector with no last updated time', () => {
expect(
getLastUpdated({
...connectorIndex,
connector: { ...connectorIndex.connector, last_synced: 'last_synced' },
})
).toEqual('last_synced');
});
it('should return null for api', () => {
expect(getLastUpdated(apiIndex)).toEqual(null);
});
});
describe('indexToViewIndex', () => {
it('should apply above transformations to viewIndex', () => {
expect(indexToViewIndex(connectorIndex)).toEqual({
...connectorIndex,
ingestionMethod: getIngestionMethod(connectorIndex),
ingestionStatus: getIngestionStatus(connectorIndex),
lastUpdated: getLastUpdated(connectorIndex),
});
});
});
});

View file

@ -0,0 +1,135 @@
/*
* 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 moment from 'moment';
import { i18n } from '@kbn/i18n';
import { SyncStatus, ConnectorStatus } from '../../../../common/types/connectors';
import {
ConnectorIndex,
CrawlerIndex,
ElasticsearchIndexWithIngestion,
} from '../../../../common/types/indices';
import {
ApiViewIndex,
ConnectorViewIndex,
CrawlerViewIndex,
ElasticsearchViewIndex,
IngestionMethod,
IngestionStatus,
} from '../types';
export function isConnectorIndex(
index: ElasticsearchIndexWithIngestion | undefined
): index is ConnectorIndex {
return !!(index as ConnectorIndex)?.connector;
}
export function isCrawlerIndex(
index: ElasticsearchIndexWithIngestion | undefined
): index is CrawlerIndex {
return !!(index as CrawlerIndex)?.crawler;
}
export function isApiIndex(index: ElasticsearchIndexWithIngestion | undefined): boolean {
if (!index) {
return false;
}
return !isConnectorIndex(index) && !isCrawlerIndex(index);
}
export function isConnectorViewIndex(index: ElasticsearchViewIndex): index is ConnectorViewIndex {
return !!(index as ConnectorViewIndex)?.connector;
}
export function isCrawlerViewIndex(index: ElasticsearchViewIndex): index is CrawlerViewIndex {
return !!(index as CrawlerViewIndex)?.crawler;
}
export function isApiViewIndex(index: ElasticsearchViewIndex): index is ApiViewIndex {
return !!index && !isConnectorViewIndex(index) && !isCrawlerViewIndex(index);
}
export function getIngestionMethod(index?: ElasticsearchIndexWithIngestion): IngestionMethod {
if (!index) return IngestionMethod.API;
if (isConnectorIndex(index)) {
return IngestionMethod.CONNECTOR;
}
if (isCrawlerIndex(index)) {
return IngestionMethod.CRAWLER;
}
return IngestionMethod.API;
}
export function getIngestionStatus(index?: ElasticsearchIndexWithIngestion): IngestionStatus {
if (!index || isApiIndex(index)) {
return IngestionStatus.CONNECTED;
}
if (isConnectorIndex(index)) {
if (
index.connector.last_seen &&
moment(index.connector.last_seen).isBefore(moment().subtract(30, 'minutes'))
) {
return IngestionStatus.ERROR;
}
if (index.connector.last_sync_status === SyncStatus.ERROR) {
return IngestionStatus.SYNC_ERROR;
}
if (index.connector.status === ConnectorStatus.CONNECTED) {
return IngestionStatus.CONNECTED;
}
if (index.connector.status === ConnectorStatus.ERROR) {
return IngestionStatus.ERROR;
}
}
return IngestionStatus.INCOMPLETE;
}
export function getLastUpdated(index?: ElasticsearchIndexWithIngestion): string | null {
return isConnectorIndex(index) ? index.connector.last_synced ?? 'never' : null;
}
export function indexToViewIndex(index: ConnectorIndex): ConnectorViewIndex;
export function indexToViewIndex(index: CrawlerIndex): CrawlerViewIndex;
export function indexToViewIndex(index: ElasticsearchIndexWithIngestion): ApiViewIndex {
const extraFields = {
ingestionMethod: getIngestionMethod(index),
ingestionStatus: getIngestionStatus(index),
lastUpdated: getLastUpdated(index),
};
if (isConnectorIndex(index)) {
const connectorResult: ConnectorViewIndex = { ...index, ...extraFields };
return connectorResult;
}
if (isCrawlerIndex(index)) {
const crawlerResult: CrawlerViewIndex = { ...index, ...extraFields };
return crawlerResult;
}
const apiResult: ApiViewIndex = { ...index, ...extraFields };
return apiResult;
}
export function ingestionMethodToText(ingestionMethod: IngestionMethod) {
if (ingestionMethod === IngestionMethod.CONNECTOR) {
return i18n.translate(
'xpack.enterpriseSearch.content.searchIndices.ingestionMethod.connector',
{
defaultMessage: 'Connector',
}
);
}
if (ingestionMethod === IngestionMethod.CRAWLER) {
return i18n.translate('xpack.enterpriseSearch.content.searchIndices.ingestionMethod.crawler', {
defaultMessage: 'Crawler',
});
}
return i18n.translate('xpack.enterpriseSearch.content.searchIndices.ingestionMethod.api', {
defaultMessage: 'API',
});
}

View file

@ -72,7 +72,7 @@ describe('Setup Indices', () => {
},
indexed_document_count: { type: 'integer' },
status: {
type: 'object',
type: 'keyword',
},
worker_hostname: { type: 'keyword' },
},

View file

@ -78,7 +78,7 @@ const indices: IndexDefinition[] = [
},
indexed_document_count: { type: 'integer' },
status: {
type: 'object',
type: 'keyword',
},
worker_hostname: { type: 'keyword' },
},

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IScopedClusterClient } from '@kbn/core/server';
import { CONNECTORS_INDEX } from '../..';
import { ErrorCode } from '../../../common/types/error_codes';
import { startConnectorSync } from './start_sync';
describe('addConnector lib function', () => {
const mockClient = {
asCurrentUser: {
get: jest.fn(),
index: jest.fn(),
indices: {
refresh: jest.fn(),
},
},
asInternalUser: {},
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should start a sync', async () => {
mockClient.asCurrentUser.get.mockImplementationOnce(() => {
return Promise.resolve({
_source: {
api_key_id: null,
configuration: {},
created_at: null,
index_name: 'index_name',
last_seen: null,
last_sync_error: null,
last_sync_status: null,
last_synced: null,
scheduling: { enabled: true, interval: '1 2 3 4 5' },
service_type: null,
status: 'not connected',
sync_now: false,
},
index: CONNECTORS_INDEX,
});
});
mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'fakeId' }));
await expect(
startConnectorSync(mockClient as unknown as IScopedClusterClient, 'connectorId')
).resolves.toEqual({ _id: 'fakeId' });
expect(mockClient.asCurrentUser.index).toHaveBeenCalledWith({
document: {
api_key_id: null,
configuration: {},
created_at: null,
index_name: 'index_name',
last_seen: null,
last_sync_error: null,
last_sync_status: null,
last_synced: null,
scheduling: { enabled: true, interval: '1 2 3 4 5' },
service_type: null,
status: 'not connected',
sync_now: true,
},
id: 'connectorId',
index: CONNECTORS_INDEX,
});
expect(mockClient.asCurrentUser.indices.refresh).toHaveBeenCalledWith({
index: CONNECTORS_INDEX,
});
});
it('should not create index if there is no connector', async () => {
mockClient.asCurrentUser.get.mockImplementationOnce(() => {
return Promise.resolve({});
});
await expect(
startConnectorSync(mockClient as unknown as IScopedClusterClient, 'connectorId')
).rejects.toEqual(new Error(ErrorCode.RESOURCE_NOT_FOUND));
expect(mockClient.asCurrentUser.index).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IScopedClusterClient } from '@kbn/core/server';
import { CONNECTORS_INDEX } from '../..';
import { ConnectorDocument } from '../../../common/types/connectors';
import { ErrorCode } from '../../../common/types/error_codes';
export const startConnectorSync = async (client: IScopedClusterClient, connectorId: string) => {
const connectorResult = await client.asCurrentUser.get<ConnectorDocument>({
id: connectorId,
index: CONNECTORS_INDEX,
});
const connector = connectorResult._source;
if (connector) {
const result = await client.asCurrentUser.index<ConnectorDocument>({
document: { ...connector, sync_now: true },
id: connectorId,
index: CONNECTORS_INDEX,
});
await client.asCurrentUser.indices.refresh({ index: CONNECTORS_INDEX });
return result;
} else {
throw new Error(ErrorCode.RESOURCE_NOT_FOUND);
}
};

View file

@ -16,6 +16,9 @@ describe('addConnector lib function', () => {
asCurrentUser: {
get: jest.fn(),
index: jest.fn(),
indices: {
refresh: jest.fn(),
},
},
asInternalUser: {},
};
@ -36,7 +39,7 @@ describe('addConnector lib function', () => {
last_sync_error: null,
last_sync_status: null,
last_synced: null,
scheduling: { enabled: true, interval: '1 2 3 4 5' },
scheduling: { enabled: false, interval: '* * * * *' },
service_type: null,
status: 'not connected',
sync_now: false,
@ -70,9 +73,12 @@ describe('addConnector lib function', () => {
id: 'connectorId',
index: CONNECTORS_INDEX,
});
expect(mockClient.asCurrentUser.indices.refresh).toHaveBeenCalledWith({
index: CONNECTORS_INDEX,
});
});
it('should not create index if there is no connector', async () => {
it('should not index document if there is no connector', async () => {
mockClient.asCurrentUser.get.mockImplementationOnce(() => {
return Promise.resolve({});
});

View file

@ -23,11 +23,13 @@ export const updateConnectorScheduling = async (
});
const connector = connectorResult._source;
if (connector) {
return await client.asCurrentUser.index<ConnectorDocument>({
const result = await client.asCurrentUser.index<ConnectorDocument>({
document: { ...connector, scheduling },
id: connectorId,
index: CONNECTORS_INDEX,
});
await client.asCurrentUser.indices.refresh({ index: CONNECTORS_INDEX });
return result;
} else {
throw new Error(
i18n.translate('xpack.enterpriseSearch.server.connectors.scheduling.error', {

View file

@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n';
import { ErrorCode } from '../../../common/types/error_codes';
import { addConnector } from '../../lib/connectors/add_connector';
import { startConnectorSync } from '../../lib/connectors/start_sync';
import { updateConnectorConfiguration } from '../../lib/connectors/update_connector_configuration';
import { updateConnectorScheduling } from '../../lib/connectors/update_connector_scheduling';
@ -124,4 +125,28 @@ export function registerConnectorRoutes({ router }: RouteDependencies) {
}
}
);
router.post(
{
path: '/internal/enterprise_search/connectors/{connectorId}/start_sync',
validate: {
params: schema.object({
connectorId: schema.string(),
}),
},
},
async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
try {
await startConnectorSync(client, request.params.connectorId);
return response.ok();
} catch (error) {
return response.customError({
body: i18n.translate('xpack.enterpriseSearch.server.routes.updateConnector.error', {
defaultMessage: 'Error fetching data from Enterprise Search',
}),
statusCode: 502,
});
}
}
);
}