[Search] Add ability to cancel syncs individually (#180739)

## Summary


2eda78f4-8f44-407d-8e45-e9447e49d3e1


Add ability to cancel syncs individually via connectors api. Add loading
indicator for sync jobs table
Add delete syncs confirmation modal.
Add listeners for the syncs to trigger loading and refetching jobs with
2 sec delays.


### Checklist

Delete any items that are not applicable to this PR.

- [x] 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)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [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
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] 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))
- [x] 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))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Efe Gürkan YALAMAN 2024-04-15 12:40:06 +02:00 committed by GitHub
parent af34cc88a4
commit 3670d5eafc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 578 additions and 37 deletions

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { CancelSyncJobModal } from './sync_job_cancel_modal';
import '@testing-library/jest-dom/extend-expect';
import { I18nProvider } from '@kbn/i18n-react';
describe('CancelSyncJobModal', () => {
const mockSyncJobId = '123';
const mockOnConfirmCb = jest.fn();
const mockOnCancel = jest.fn();
beforeEach(() => {
render(
<I18nProvider>
<CancelSyncJobModal
syncJobId={mockSyncJobId}
onConfirmCb={mockOnConfirmCb}
onCancel={mockOnCancel}
isLoading={false}
errorMessages={[]}
/>
</I18nProvider>
);
});
test('renders the sync job ID', () => {
const syncJobIdElement = screen.getByTestId('confirmModalBodyText');
expect(syncJobIdElement).toHaveTextContent(`Sync job ID: ${mockSyncJobId}`);
});
test('calls onConfirmCb when confirm button is clicked', () => {
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirmCb).toHaveBeenCalledWith(mockSyncJobId);
});
test('calls onCancel when cancel button is clicked', () => {
const cancelButton = screen.getByTestId('confirmModalCancelButton');
fireEvent.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,58 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiConfirmModal, EuiText, EuiCode, EuiSpacer, EuiConfirmModalProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
export type CancelSyncModalProps = Omit<EuiConfirmModalProps, 'onConfirm'> & {
onConfirmCb: (syncJobId: string) => void;
syncJobId: string;
errorMessages?: string[];
};
export const CancelSyncJobModal: React.FC<CancelSyncModalProps> = ({
syncJobId,
onCancel,
onConfirmCb,
isLoading,
}) => {
return (
<EuiConfirmModal
title={i18n.translate('searchConnectors.syncJobs.cancelSyncModal.title', {
defaultMessage: 'Cancel sync job',
})}
onCancel={onCancel}
onConfirm={() => onConfirmCb(syncJobId)}
cancelButtonText={i18n.translate('searchConnectors.syncJobs.cancelSyncModal.cancelButton', {
defaultMessage: 'Cancel',
})}
confirmButtonText={i18n.translate('searchConnectors.syncJobs.cancelSyncModal.confirmButton', {
defaultMessage: 'Confirm',
})}
buttonColor="danger"
confirmButtonDisabled={isLoading}
isLoading={isLoading}
>
<EuiText size="s">
<FormattedMessage
id="searchConnectors.syncJobs.cancelSyncModal.description"
defaultMessage="Are you sure you want to cancel this sync job?"
/>
<EuiSpacer size="m" />
<FormattedMessage
id="searchConnectors.syncJobs.cancelSyncModal.syncJobId"
defaultMessage="Sync job ID:"
/>
&nbsp;
<EuiCode>{syncJobId}</EuiCode>
</EuiText>
</EuiConfirmModal>
);
};

View file

@ -13,16 +13,18 @@ import {
EuiBadge,
EuiBasicTable,
EuiBasicTableColumn,
EuiButtonIcon,
Pagination,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ConnectorSyncJob, SyncJobType, SyncStatus } from '../..';
import { ConnectorSyncJob, isSyncCancellable, SyncJobType, SyncStatus } from '../..';
import { syncJobTypeToText, syncStatusToColor, syncStatusToText } from '../..';
import { durationToText, getSyncJobDuration } from '../../utils/duration_to_text';
import { FormattedDateTime } from '../../utils/formatted_date_time';
import { SyncJobFlyout } from './sync_job_flyout';
import { CancelSyncJobModal, CancelSyncModalProps } from './sync_job_cancel_modal';
interface SyncJobHistoryTableProps {
isLoading?: boolean;
@ -30,6 +32,10 @@ interface SyncJobHistoryTableProps {
pagination: Pagination;
syncJobs: ConnectorSyncJob[];
type: 'content' | 'access_control';
cancelConfirmModalProps?: Pick<CancelSyncModalProps, 'isLoading' | 'onConfirmCb'> & {
syncJobIdToCancel?: ConnectorSyncJob['id'];
setSyncJobIdToCancel: (syncJobId: ConnectorSyncJob['id'] | undefined) => void;
};
}
export const SyncJobsTable: React.FC<SyncJobHistoryTableProps> = ({
@ -38,6 +44,12 @@ export const SyncJobsTable: React.FC<SyncJobHistoryTableProps> = ({
pagination,
syncJobs,
type,
cancelConfirmModalProps = {
onConfirmCb: () => {},
isLoading: false,
setSyncJobIdToCancel: () => {},
syncJobIdToCancel: undefined,
},
}) => {
const [selectedSyncJob, setSelectedSyncJob] = useState<ConnectorSyncJob | undefined>(undefined);
const columns: Array<EuiBasicTableColumn<ConnectorSyncJob>> = [
@ -127,6 +139,33 @@ export const SyncJobsTable: React.FC<SyncJobHistoryTableProps> = ({
onClick: (job) => setSelectedSyncJob(job),
type: 'icon',
},
...(cancelConfirmModalProps
? [
{
render: (job: ConnectorSyncJob) => {
return isSyncCancellable(job.status) ? (
<EuiButtonIcon
iconType="cross"
color="danger"
onClick={() => cancelConfirmModalProps.setSyncJobIdToCancel(job.id)}
aria-label={i18n.translate(
'searchConnectors.index.syncJobs.actions.cancelSyncJob.caption',
{
defaultMessage: 'Cancel this sync job',
}
)}
>
{i18n.translate('searchConnectors.index.syncJobs.actions.deleteJob.caption', {
defaultMessage: 'Delete',
})}
</EuiButtonIcon>
) : (
<></>
);
},
},
]
: []),
],
},
];
@ -136,6 +175,13 @@ export const SyncJobsTable: React.FC<SyncJobHistoryTableProps> = ({
{Boolean(selectedSyncJob) && (
<SyncJobFlyout onClose={() => setSelectedSyncJob(undefined)} syncJob={selectedSyncJob} />
)}
{Boolean(cancelConfirmModalProps) && cancelConfirmModalProps?.syncJobIdToCancel && (
<CancelSyncJobModal
{...cancelConfirmModalProps}
syncJobId={cancelConfirmModalProps.syncJobIdToCancel}
onCancel={() => cancelConfirmModalProps.setSyncJobIdToCancel(undefined)}
/>
)}
<EuiBasicTable
data-test-subj={`entSearchContent-index-${type}-syncJobs-table`}
items={syncJobs}

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { cancelSync } from './cancel_sync';
describe('cancelSync lib function', () => {
const mockClient = {
transport: {
request: jest.fn(),
},
};
it('should cancel a sync', async () => {
mockClient.transport.request.mockImplementation(() => ({
success: true,
}));
await expect(cancelSync(mockClient as unknown as ElasticsearchClient, '1234')).resolves.toEqual(
{ success: true }
);
expect(mockClient.transport.request).toHaveBeenCalledWith({
method: 'PUT',
path: '/_connector/_sync_job/1234/_cancel',
});
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { ConnectorAPICancelSyncResponse } from '../types';
export const cancelSync = async (client: ElasticsearchClient, syncJobId: string) => {
const result = await client.transport.request<ConnectorAPICancelSyncResponse>({
method: 'PUT',
path: `/_connector/_sync_job/${syncJobId}/_cancel`,
});
return result;
};

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
export * from './cancel_sync';
export * from './cancel_syncs';
export * from './collect_connector_stats';
export * from './create_connector';

View file

@ -5,7 +5,9 @@
"types": [
"jest",
"node",
"react"
"react",
"@testing-library/jest-dom",
"@testing-library/react",
]
},
"include": [

View file

@ -22,3 +22,7 @@ export interface ConnectorsAPISyncJobResponse {
export interface ConnectorSecretCreateResponse {
id: string;
}
export interface ConnectorAPICancelSyncResponse {
success: boolean;
}

View file

@ -11,6 +11,10 @@ export interface ElasticsearchResponseError {
body?: {
error?: {
type: string;
caused_by?: {
type?: string;
reason?: string;
};
};
};
statusCode?: number;
@ -48,3 +52,12 @@ export const isMissingAliasException = (error: ElasticsearchResponseError) =>
error.meta?.statusCode === 404 &&
typeof error.meta?.body?.error === 'string' &&
MISSING_ALIAS_ERROR.test(error.meta?.body?.error);
export const isStatusTransitionException = (error: ElasticsearchResponseError) => {
return (
error.meta?.statusCode === 400 &&
error.meta?.body?.error?.type === 'status_exception' &&
error.meta?.body.error?.caused_by?.type ===
'connector_sync_job_invalid_status_transition_exception'
);
};

View file

@ -6,9 +6,8 @@
* Side Public License, v 1.
*/
import { SyncStatus } from '..';
import { syncStatusToColor, syncStatusToText } from './sync_status_to_text';
import { isSyncCancellable, SyncStatus } from '..';
describe('syncStatusToText', () => {
it('should return correct value for completed', () => {
@ -57,3 +56,33 @@ describe('syncStatusToColor', () => {
expect(syncStatusToColor(SyncStatus.SUSPENDED)).toEqual('warning');
});
});
describe('isSyncCancellable', () => {
it('should return true for in progress status', () => {
expect(isSyncCancellable(SyncStatus.IN_PROGRESS)).toBe(true);
});
it('should return true for pending status', () => {
expect(isSyncCancellable(SyncStatus.PENDING)).toBe(true);
});
it('should return true for suspended status', () => {
expect(isSyncCancellable(SyncStatus.SUSPENDED)).toBe(true);
});
it('should return false for canceling status', () => {
expect(isSyncCancellable(SyncStatus.CANCELING)).toBe(false);
});
it('should return false for completed status', () => {
expect(isSyncCancellable(SyncStatus.COMPLETED)).toBe(false);
});
it('should return false for error status', () => {
expect(isSyncCancellable(SyncStatus.ERROR)).toBe(false);
});
it('should return false for canceled status', () => {
expect(isSyncCancellable(SyncStatus.CANCELED)).toBe(false);
});
});

View file

@ -62,6 +62,14 @@ export function syncStatusToColor(status: SyncStatus): string {
}
}
export const isSyncCancellable = (syncStatus: SyncStatus): boolean => {
return (
syncStatus === SyncStatus.IN_PROGRESS ||
syncStatus === SyncStatus.PENDING ||
syncStatus === SyncStatus.SUSPENDED
);
};
export const syncJobTypeToText = (syncType: SyncJobType): string => {
switch (syncType) {
case SyncJobType.FULL:

View file

@ -25,6 +25,7 @@ export enum ErrorCode {
SEARCH_APPLICATION_ALREADY_EXISTS = 'search_application_already_exists',
SEARCH_APPLICATION_NAME_INVALID = 'search_application_name_invalid',
SEARCH_APPLICATION_NOT_FOUND = 'search_application_not_found',
STATUS_TRANSITION_ERROR = 'status_transition_error',
UNAUTHORIZED = 'unauthorized',
UNCAUGHT_EXCEPTION = 'uncaught_exception',
}

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.
*/
// write tests that checks cancelSync API logic calls correct endpoint
import { mockHttpValues } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { cancelSync } from './cancel_sync_api_logic';
describe('CancelSyncApiLogic', () => {
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
});
describe('cancelSync', () => {
it('calls correct api', async () => {
const promise = Promise.resolve({ success: true });
http.put.mockReturnValue(promise);
const result = cancelSync({ syncJobId: 'syncJobId1' });
await nextTick();
expect(http.put).toHaveBeenCalledWith(
'/internal/enterprise_search/connectors/syncJobId1/cancel_sync'
);
await expect(result).resolves.toEqual({ success: true });
});
});
});

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface CancelSyncApiArgs {
syncJobId: string;
}
export interface CancelSyncApiResponse {
success: boolean;
}
export const cancelSync = async ({ syncJobId }: CancelSyncApiArgs) => {
const route = `/internal/enterprise_search/connectors/${syncJobId}/cancel_sync`;
return await HttpLogic.values.http.put(route);
};
export const CancelSyncApiLogic = createApiLogic(['cancel_sync_api_logic'], cancelSync, {
showErrorFlash: true,
showSuccessFlashFn: () =>
i18n.translate('xpack.enterpriseSearch.content.searchIndex.cancelSync.successMessage', {
defaultMessage: 'Successfully canceled sync',
}),
});
export type CancelSyncApiActions = Actions<CancelSyncApiArgs, CancelSyncApiResponse>;

View file

@ -35,8 +35,7 @@ import { ConnectorViewLogic } from './connector_view_logic';
export const ConnectorDetailOverview: React.FC = () => {
const { indexData } = useValues(IndexViewLogic);
const { connector } = useValues(ConnectorViewLogic);
const error = null;
const { connector, error } = useValues(ConnectorViewLogic);
const { isCloud } = useValues(KibanaLogic);
const { showModal } = useActions(ConvertConnectorLogic);
const { isModalVisible } = useValues(ConvertConnectorLogic);

View file

@ -22,7 +22,7 @@ interface CancelSyncsLogicValues {
isConnectorIndex: boolean;
}
interface CancelSyncsLogicActions {
export interface CancelSyncsLogicActions {
cancelSyncs: () => void;
cancelSyncsApiError: CancelSyncsApiActions['apiError'];
cancelSyncsApiSuccess: CancelSyncsApiActions['apiSuccess'];

View file

@ -240,6 +240,8 @@ export const IndexViewLogic = kea<MakeLogicType<IndexViewValues, IndexViewAction
false,
{
fetchIndexApiSuccess: () => false,
startAccessControlSync: () => true,
startIncrementalSync: () => true,
startSyncApiSuccess: () => true,
},
],

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { type } from 'io-ts';
import { useActions, useValues } from 'kea';
import { EuiButtonGroup } from '@elastic/eui';
@ -25,11 +24,19 @@ import { SyncJobsViewLogic } from './sync_jobs_view_logic';
export const SyncJobs: React.FC = () => {
const { hasDocumentLevelSecurityFeature } = useValues(IndexViewLogic);
const { productFeatures } = useValues(KibanaLogic);
const [selectedSyncJobCategory, setSelectedSyncJobCategory] = useState<string>('content');
const shouldShowAccessSyncs =
productFeatures.hasDocumentLevelSecurityEnabled && hasDocumentLevelSecurityFeature;
const { connectorId, syncJobsPagination: pagination, syncJobs } = useValues(SyncJobsViewLogic);
const { fetchSyncJobs } = useActions(SyncJobsViewLogic);
const {
connectorId,
syncJobsPagination: pagination,
syncJobs,
cancelSyncJobLoading,
syncJobToCancel,
selectedSyncJobCategory,
syncTriggeredLocally,
} = useValues(SyncJobsViewLogic);
const { fetchSyncJobs, cancelSyncJob, setCancelSyncJob, setSelectedSyncJobCategory } =
useActions(SyncJobsViewLogic);
useEffect(() => {
if (connectorId) {
@ -37,10 +44,10 @@ export const SyncJobs: React.FC = () => {
connectorId,
from: pagination.pageIndex * (pagination.pageSize || 0),
size: pagination.pageSize ?? 10,
type: selectedSyncJobCategory as 'access_control' | 'content',
type: selectedSyncJobCategory,
});
}
}, [connectorId, selectedSyncJobCategory, type]);
}, [connectorId, selectedSyncJobCategory]);
return (
<>
@ -56,7 +63,9 @@ export const SyncJobs: React.FC = () => {
)}
idSelected={selectedSyncJobCategory}
onChange={(optionId) => {
setSelectedSyncJobCategory(optionId);
if (optionId === 'content' || optionId === 'access_control') {
setSelectedSyncJobCategory(optionId);
}
}}
options={[
{
@ -79,6 +88,7 @@ export const SyncJobs: React.FC = () => {
)}
{selectedSyncJobCategory === 'content' ? (
<SyncJobsTable
isLoading={syncTriggeredLocally}
onPaginate={({ page: { index, size } }) => {
if (connectorId) {
fetchSyncJobs({
@ -92,9 +102,18 @@ export const SyncJobs: React.FC = () => {
pagination={pagination}
syncJobs={syncJobs}
type="content"
cancelConfirmModalProps={{
isLoading: cancelSyncJobLoading,
onConfirmCb: (syncJobId: string) => {
cancelSyncJob({ syncJobId });
},
setSyncJobIdToCancel: setCancelSyncJob,
syncJobIdToCancel: syncJobToCancel ?? undefined,
}}
/>
) : (
<SyncJobsTable
isLoading={syncTriggeredLocally}
onPaginate={({ page: { index, size } }) => {
if (connectorId) {
fetchSyncJobs({
@ -105,6 +124,14 @@ export const SyncJobs: React.FC = () => {
});
}
}}
cancelConfirmModalProps={{
isLoading: cancelSyncJobLoading,
onConfirmCb: (syncJobId: string) => {
cancelSyncJob({ syncJobId });
},
setSyncJobIdToCancel: setCancelSyncJob,
syncJobIdToCancel: syncJobToCancel ?? undefined,
}}
pagination={pagination}
syncJobs={syncJobs}
type="access_control"

View file

@ -23,7 +23,11 @@ import { SyncJobView, SyncJobsViewLogic } from './sync_jobs_view_logic';
// We can't test fetchTimeOutId because this will get set whenever the logic is created
// And the timeoutId is non-deterministic. We use expect.object.containing throughout this test file
const DEFAULT_VALUES = {
cancelSyncJobLoading: false,
cancelSyncJobStatus: Status.IDLE,
connectorId: null,
selectedSyncJobCategory: 'content',
syncJobToCancel: null,
syncJobs: [],
syncJobsData: undefined,
syncJobsLoading: true,
@ -33,6 +37,7 @@ const DEFAULT_VALUES = {
totalItemCount: 0,
},
syncJobsStatus: Status.IDLE,
syncTriggeredLocally: false,
};
describe('SyncJobsViewLogic', () => {

View file

@ -7,6 +7,7 @@
import { kea, MakeLogicType } from 'kea';
import { isEqual } from 'lodash';
import moment from 'moment';
import { Pagination } from '@elastic/eui';
@ -16,35 +17,68 @@ import { Status } from '../../../../../../common/types/api';
import { Paginate } from '../../../../../../common/types/pagination';
import { Actions } from '../../../../shared/api_logic/create_api_logic';
import {
CancelSyncApiActions,
CancelSyncApiLogic,
} from '../../../api/connector/cancel_sync_api_logic';
import {
FetchSyncJobsApiLogic,
FetchSyncJobsArgs,
FetchSyncJobsResponse,
} from '../../../api/connector/fetch_sync_jobs_api_logic';
import { IndexViewLogic } from '../index_view_logic';
import { CancelSyncsLogic, CancelSyncsLogicActions } from '../connector/cancel_syncs_logic';
import { IndexViewActions, IndexViewLogic } from '../index_view_logic';
const UI_REFRESH_INTERVAL = 2000;
export interface SyncJobView extends ConnectorSyncJob {
duration: moment.Duration;
lastSync: string;
duration: moment.Duration | undefined;
lastSync: string | null;
}
export interface IndexViewActions {
export interface SyncJobsViewActions {
cancelSyncError: CancelSyncApiActions['apiError'];
cancelSyncJob: CancelSyncApiActions['makeRequest'];
cancelSyncSuccess: CancelSyncApiActions['apiSuccess'];
cancelSyncsApiError: CancelSyncsLogicActions['cancelSyncsApiError'];
cancelSyncsApiSuccess: CancelSyncsLogicActions['cancelSyncsApiSuccess'];
fetchSyncJobs: Actions<FetchSyncJobsArgs, FetchSyncJobsResponse>['makeRequest'];
fetchSyncJobsApiSuccess: Actions<FetchSyncJobsArgs, FetchSyncJobsResponse>['apiSuccess'];
fetchSyncJobsError: Actions<FetchSyncJobsArgs, FetchSyncJobsResponse>['apiError'];
refetchSyncJobs: () => void;
resetCancelSyncJobApi: CancelSyncApiActions['apiReset'];
setCancelSyncJob: (syncJobId: ConnectorSyncJob['id'] | undefined) => {
syncJobId: ConnectorSyncJob['id'] | null;
};
setSelectedSyncJobCategory: (category: 'content' | 'access_control') => {
category: 'content' | 'access_control';
};
startAccessControlSync: IndexViewActions['startAccessControlSync'];
startIncrementalSync: IndexViewActions['startIncrementalSync'];
startSync: IndexViewActions['startSync'];
}
export interface IndexViewValues {
export interface SyncJobsViewValues {
cancelSyncJobLoading: boolean;
cancelSyncJobStatus: Status;
connectorId: string | null;
selectedSyncJobCategory: 'content' | 'access_control';
syncJobToCancel: ConnectorSyncJob['id'] | null;
syncJobs: SyncJobView[];
syncJobsData: Paginate<ConnectorSyncJob> | null;
syncJobsLoading: boolean;
syncJobsPagination: Pagination;
syncJobsStatus: Status;
syncTriggeredLocally: boolean;
}
export const SyncJobsViewLogic = kea<MakeLogicType<IndexViewValues, IndexViewActions>>({
actions: {},
export const SyncJobsViewLogic = kea<MakeLogicType<SyncJobsViewValues, SyncJobsViewActions>>({
actions: {
refetchSyncJobs: true,
setCancelSyncJob: (syncJobId) => ({ syncJobId: syncJobId ?? null }),
setSelectedSyncJobCategory: (category) => ({ category }),
},
connect: {
actions: [
FetchSyncJobsApiLogic,
@ -54,30 +88,137 @@ export const SyncJobsViewLogic = kea<MakeLogicType<IndexViewValues, IndexViewAct
'apiSuccess as fetchSyncJobsApiSuccess',
'makeRequest as fetchSyncJobs',
],
CancelSyncApiLogic,
[
'apiError as cancelSyncError',
'apiReset as resetCancelSyncJobApi',
'apiSuccess as cancelSyncSuccess',
'makeRequest as cancelSyncJob',
],
CancelSyncsLogic,
['cancelSyncsApiError', 'cancelSyncsApiSuccess', 'cancelSyncs'],
IndexViewLogic,
['startSync', 'startIncrementalSync', 'startAccessControlSync'],
],
values: [
IndexViewLogic,
['connectorId'],
FetchSyncJobsApiLogic,
['data as syncJobsData', 'status as syncJobsStatus'],
CancelSyncApiLogic,
['status as cancelSyncJobStatus'],
],
},
listeners: ({ actions, values }) => ({
cancelSyncError: async (_, breakpoint) => {
actions.resetCancelSyncJobApi();
await breakpoint(UI_REFRESH_INTERVAL);
if (values.connectorId) {
actions.refetchSyncJobs();
}
},
cancelSyncSuccess: async (_, breakpoint) => {
actions.resetCancelSyncJobApi();
await breakpoint(UI_REFRESH_INTERVAL);
if (values.connectorId) {
actions.refetchSyncJobs();
}
},
cancelSyncsApiError: async (_, breakpoint) => {
await breakpoint(UI_REFRESH_INTERVAL);
if (values.connectorId) {
actions.refetchSyncJobs();
}
},
cancelSyncsApiSuccess: async (_, breakpoint) => {
await breakpoint(UI_REFRESH_INTERVAL);
if (values.connectorId) {
actions.refetchSyncJobs();
}
},
refetchSyncJobs: () => {
if (values.connectorId) {
actions.fetchSyncJobs({
connectorId: values.connectorId,
from: values.syncJobsPagination.pageIndex * (values.syncJobsPagination.pageSize || 0),
size: values.syncJobsPagination.pageSize ?? 10,
type: values.selectedSyncJobCategory,
});
}
},
startAccessControlSync: async (_, breakpoint) => {
await breakpoint(UI_REFRESH_INTERVAL);
if (values.connectorId) {
actions.refetchSyncJobs();
}
},
startIncrementalSync: async (_, breakpoint) => {
await breakpoint(UI_REFRESH_INTERVAL);
if (values.connectorId) {
actions.refetchSyncJobs();
}
},
startSync: async (_, breakpoint) => {
await breakpoint(UI_REFRESH_INTERVAL);
if (values.connectorId) {
actions.refetchSyncJobs();
}
},
}),
path: ['enterprise_search', 'content', 'sync_jobs_view_logic'],
selectors: ({ selectors }) => ({
reducers: {
selectedSyncJobCategory: [
'content',
{
setSelectedSyncJobCategory: (_, { category }) => category,
},
],
syncJobToCancel: [
null,
{
resetCancelSyncJobApi: () => null,
setCancelSyncJob: (_, { syncJobId }) => syncJobId ?? null,
},
],
syncJobs: [
() => [selectors.syncJobsData],
(data?: Paginate<ConnectorSyncJob>) =>
data?.data.map((syncJob) => {
return {
...syncJob,
duration: syncJob.started_at
? moment.duration(
moment(syncJob.completed_at || new Date()).diff(moment(syncJob.started_at))
)
: undefined,
lastSync: syncJob.completed_at,
};
}) ?? [],
[],
{
fetchSyncJobsApiSuccess: (currentState, { data }) => {
const newState =
data?.map((syncJob) => {
return {
...syncJob,
duration: syncJob.started_at
? moment.duration(
moment(syncJob.completed_at || new Date()).diff(moment(syncJob.started_at))
)
: undefined,
lastSync: syncJob.completed_at,
};
}) ?? [];
return isEqual(currentState, newState) ? currentState : newState;
},
},
],
syncTriggeredLocally: [
false,
{
cancelSyncError: () => true,
cancelSyncJob: () => true,
cancelSyncs: () => true,
fetchSyncJobsApiSuccess: () => false,
startAccessControlSync: () => true,
startIncrementalSync: () => true,
startSync: () => true,
},
],
},
selectors: ({ selectors }) => ({
cancelSyncJobLoading: [
() => [selectors.cancelSyncJobStatus],
(status: Status) => status === Status.LOADING,
],
syncJobsLoading: [
() => [selectors.syncJobsStatus],

View file

@ -6,11 +6,13 @@
*/
import { schema } from '@kbn/config-schema';
import { ElasticsearchErrorDetails } from '@kbn/es-errors';
import { i18n } from '@kbn/i18n';
import {
CONNECTORS_INDEX,
cancelSync,
deleteConnectorById,
deleteConnectorSecret,
fetchConnectorById,
@ -28,7 +30,10 @@ import {
import { ConnectorStatus, FilteringRule, SyncJobType } from '@kbn/search-connectors';
import { cancelSyncs } from '@kbn/search-connectors/lib/cancel_syncs';
import { isResourceNotFoundException } from '@kbn/search-connectors/utils/identify_exceptions';
import {
isResourceNotFoundException,
isStatusTransitionException,
} from '@kbn/search-connectors/utils/identify_exceptions';
import { ErrorCode } from '../../../common/types/error_codes';
import { addConnector } from '../../lib/connectors/add_connector';
@ -117,6 +122,40 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) {
})
);
router.put(
{
path: '/internal/enterprise_search/connectors/{syncJobId}/cancel_sync',
validate: {
params: schema.object({
syncJobId: schema.string(),
}),
},
},
elasticsearchErrorHandler(log, async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
try {
await cancelSync(client.asCurrentUser, request.params.syncJobId);
} catch (error) {
if (isStatusTransitionException(error)) {
return createError({
errorCode: ErrorCode.STATUS_TRANSITION_ERROR,
message: i18n.translate(
'xpack.enterpriseSearch.server.routes.connectors.statusTransitionError',
{
defaultMessage:
'Connector sync job cannot be cancelled. Connector is already cancelled or not in a cancelable state.',
}
),
response,
statusCode: 400,
});
}
throw error;
}
return response.ok();
})
);
router.post(
{
path: '/internal/enterprise_search/connectors/{connectorId}/configuration',