mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[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:
parent
af34cc88a4
commit
3670d5eafc
21 changed files with 578 additions and 37 deletions
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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:"
|
||||
/>
|
||||
|
||||
<EuiCode>{syncJobId}</EuiCode>
|
||||
</EuiText>
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
|
|
32
packages/kbn-search-connectors/lib/cancel_sync.test.ts
Normal file
32
packages/kbn-search-connectors/lib/cancel_sync.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
18
packages/kbn-search-connectors/lib/cancel_sync.ts
Normal file
18
packages/kbn-search-connectors/lib/cancel_sync.ts
Normal 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;
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
"types": [
|
||||
"jest",
|
||||
"node",
|
||||
"react"
|
||||
"react",
|
||||
"@testing-library/jest-dom",
|
||||
"@testing-library/react",
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
|
|
@ -22,3 +22,7 @@ export interface ConnectorsAPISyncJobResponse {
|
|||
export interface ConnectorSecretCreateResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ConnectorAPICancelSyncResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>;
|
|
@ -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);
|
||||
|
|
|
@ -22,7 +22,7 @@ interface CancelSyncsLogicValues {
|
|||
isConnectorIndex: boolean;
|
||||
}
|
||||
|
||||
interface CancelSyncsLogicActions {
|
||||
export interface CancelSyncsLogicActions {
|
||||
cancelSyncs: () => void;
|
||||
cancelSyncsApiError: CancelSyncsApiActions['apiError'];
|
||||
cancelSyncsApiSuccess: CancelSyncsApiActions['apiSuccess'];
|
||||
|
|
|
@ -240,6 +240,8 @@ export const IndexViewLogic = kea<MakeLogicType<IndexViewValues, IndexViewAction
|
|||
false,
|
||||
{
|
||||
fetchIndexApiSuccess: () => false,
|
||||
startAccessControlSync: () => true,
|
||||
startIncrementalSync: () => true,
|
||||
startSyncApiSuccess: () => true,
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue