diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts index 45b8b23cae477..605265f7311ba 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts @@ -6,7 +6,7 @@ */ import sinon, { SinonFakeServer } from 'sinon'; -import { API_BASE_PATH } from '../../../common/constants'; +import { API_BASE_PATH } from '../../../common'; type HttpResponse = Record | any[]; @@ -54,7 +54,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; const setLoadSnapshotsResponse = (response: HttpResponse = {}) => { - const defaultResponse = { errors: {}, snapshots: [], repositories: [] }; + const defaultResponse = { errors: {}, snapshots: [], repositories: [], total: 0 }; server.respondWith('GET', `${API_BASE_PATH}snapshots`, mockResponse(defaultResponse, response)); }; diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/home.test.ts b/x-pack/plugins/snapshot_restore/__jest__/client_integration/home.test.ts index 52303e1134f9d..071868e23f7fe 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/home.test.ts +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/home.test.ts @@ -8,7 +8,7 @@ import { act } from 'react-dom/test-utils'; import * as fixtures from '../../test/fixtures'; import { SNAPSHOT_STATE } from '../../public/application/constants'; -import { API_BASE_PATH } from '../../common/constants'; +import { API_BASE_PATH } from '../../common'; import { setupEnvironment, pageHelpers, @@ -431,6 +431,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadSnapshotsResponse({ snapshots: [], repositories: ['my-repo'], + total: 0, }); testBed = await setup(); @@ -469,6 +470,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadSnapshotsResponse({ snapshots, repositories: [REPOSITORY_NAME], + total: 2, }); testBed = await setup(); @@ -501,18 +503,10 @@ describe('', () => { }); }); - test('should show a warning if the number of snapshots exceeded the limit', () => { - // We have mocked the SNAPSHOT_LIST_MAX_SIZE to 2, so the warning should display - const { find, exists } = testBed; - expect(exists('maxSnapshotsWarning')).toBe(true); - expect(find('maxSnapshotsWarning').text()).toContain( - 'Cannot show the full list of snapshots' - ); - }); - test('should show a warning if one repository contains errors', async () => { httpRequestsMockHelpers.setLoadSnapshotsResponse({ snapshots, + total: 2, repositories: [REPOSITORY_NAME], errors: { repository_with_errors: { diff --git a/x-pack/plugins/snapshot_restore/common/constants.ts b/x-pack/plugins/snapshot_restore/common/constants.ts index a7c83ecf702e0..b18e118dc5ff6 100644 --- a/x-pack/plugins/snapshot_restore/common/constants.ts +++ b/x-pack/plugins/snapshot_restore/common/constants.ts @@ -65,9 +65,3 @@ export const TIME_UNITS: { [key: string]: 'd' | 'h' | 'm' | 's' } = { MINUTE: 'm', SECOND: 's', }; - -/** - * [Temporary workaround] In order to prevent client-side performance issues for users with a large number of snapshots, - * we set a hard-coded limit on the number of snapshots we return from the ES snapshots API - */ -export const SNAPSHOT_LIST_MAX_SIZE = 1000; diff --git a/x-pack/plugins/snapshot_restore/public/application/lib/index.ts b/x-pack/plugins/snapshot_restore/public/application/lib/index.ts index 1ec4d5b2907f2..19a42bef4cea4 100644 --- a/x-pack/plugins/snapshot_restore/public/application/lib/index.ts +++ b/x-pack/plugins/snapshot_restore/public/application/lib/index.ts @@ -6,3 +6,12 @@ */ export { useDecodedParams } from './use_decoded_params'; + +export { + SortField, + SortDirection, + SnapshotListParams, + getListParams, + getQueryFromListParams, + DEFAULT_SNAPSHOT_LIST_PARAMS, +} from './snapshot_list_params'; diff --git a/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts b/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts new file mode 100644 index 0000000000000..b75a3e01fb617 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts @@ -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 { Direction, Query } from '@elastic/eui'; + +export type SortField = + | 'snapshot' + | 'repository' + | 'indices' + | 'startTimeInMillis' + | 'durationInMillis' + | 'shards.total' + | 'shards.failed'; + +export type SortDirection = Direction; + +interface SnapshotTableParams { + sortField: SortField; + sortDirection: SortDirection; + pageIndex: number; + pageSize: number; +} +interface SnapshotSearchParams { + searchField?: string; + searchValue?: string; + searchMatch?: string; + searchOperator?: string; +} +export type SnapshotListParams = SnapshotTableParams & SnapshotSearchParams; + +// By default, we'll display the most recent snapshots at the top of the table (no query). +export const DEFAULT_SNAPSHOT_LIST_PARAMS: SnapshotListParams = { + sortField: 'startTimeInMillis', + sortDirection: 'desc', + pageIndex: 0, + pageSize: 20, +}; + +const resetSearchOptions = (listParams: SnapshotListParams): SnapshotListParams => ({ + ...listParams, + searchField: undefined, + searchValue: undefined, + searchMatch: undefined, + searchOperator: undefined, +}); + +// to init the query for repository and policyName search passed via url +export const getQueryFromListParams = (listParams: SnapshotListParams): Query => { + const { searchField, searchValue } = listParams; + if (!searchField || !searchValue) { + return Query.MATCH_ALL; + } + return Query.parse(`${searchField}=${searchValue}`); +}; + +export const getListParams = (listParams: SnapshotListParams, query: Query): SnapshotListParams => { + if (!query) { + return resetSearchOptions(listParams); + } + const clause = query.ast.clauses[0]; + if (!clause) { + return resetSearchOptions(listParams); + } + // term queries (free word search) converts to snapshot name search + if (clause.type === 'term') { + return { + ...listParams, + searchField: 'snapshot', + searchValue: String(clause.value), + searchMatch: clause.match, + searchOperator: 'eq', + }; + } + if (clause.type === 'field') { + return { + ...listParams, + searchField: clause.field, + searchValue: String(clause.value), + searchMatch: clause.match, + searchOperator: clause.operator, + }; + } + return resetSearchOptions(listParams); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/index.ts b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/index.ts similarity index 55% rename from x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/index.ts rename to x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/index.ts index 7ea85f4150900..8c69ca0297e3e 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/index.ts +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/index.ts @@ -6,3 +6,7 @@ */ export { SnapshotTable } from './snapshot_table'; +export { RepositoryError } from './repository_error'; +export { RepositoryEmptyPrompt } from './repository_empty_prompt'; +export { SnapshotEmptyPrompt } from './snapshot_empty_prompt'; +export { SnapshotSearchBar } from './snapshot_search_bar'; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_empty_prompt.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_empty_prompt.tsx new file mode 100644 index 0000000000000..4c5e050ea489c --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_empty_prompt.tsx @@ -0,0 +1,62 @@ +/* + * 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 { useHistory } from 'react-router-dom'; +import { EuiButton, EuiEmptyPrompt, EuiPageContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { reactRouterNavigate } from '../../../../../shared_imports'; +import { linkToAddRepository } from '../../../../services/navigation'; + +export const RepositoryEmptyPrompt: React.FunctionComponent = () => { + const history = useHistory(); + return ( + + + + + } + body={ + <> +

+ +

+

+ + + +

+ + } + data-test-subj="emptyPrompt" + /> +
+ ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_error.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_error.tsx new file mode 100644 index 0000000000000..d3902770333cc --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_error.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiEmptyPrompt, EuiLink, EuiPageContent } from '@elastic/eui'; +import { reactRouterNavigate } from '../../../../../shared_imports'; +import { linkToRepositories } from '../../../../services/navigation'; + +export const RepositoryError: React.FunctionComponent = () => { + const history = useHistory(); + return ( + + + + + } + body={ +

+ + + + ), + }} + /> +

+ } + /> +
+ ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_empty_prompt.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_empty_prompt.tsx new file mode 100644 index 0000000000000..2cfc1d5ebefc5 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_empty_prompt.tsx @@ -0,0 +1,123 @@ +/* + * 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, { Fragment } from 'react'; +import { useHistory } from 'react-router-dom'; +import { EuiButton, EuiEmptyPrompt, EuiIcon, EuiLink, EuiPageContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../../common'; +import { reactRouterNavigate, WithPrivileges } from '../../../../../shared_imports'; +import { linkToAddPolicy, linkToPolicies } from '../../../../services/navigation'; +import { useCore } from '../../../../app_context'; + +export const SnapshotEmptyPrompt: React.FunctionComponent<{ policiesCount: number }> = ({ + policiesCount, +}) => { + const { docLinks } = useCore(); + const history = useHistory(); + return ( + + + + + } + body={ + `cluster.${name}`)}> + {({ hasPrivileges }) => + hasPrivileges ? ( + +

+ + + + ), + }} + /> +

+

+ {policiesCount === 0 ? ( + + + + ) : ( + + + + )} +

+
+ ) : ( + +

+ +

+

+ + {' '} + + +

+
+ ) + } +
+ } + data-test-subj="emptyPrompt" + /> +
+ ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx new file mode 100644 index 0000000000000..b3e2c24e396f0 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx @@ -0,0 +1,178 @@ +/* + * 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, { useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { SearchFilterConfig } from '@elastic/eui/src/components/search_bar/search_filters'; +import { SchemaType } from '@elastic/eui/src/components/search_bar/search_box'; +import { EuiSearchBarOnChangeArgs } from '@elastic/eui/src/components/search_bar/search_bar'; +import { EuiButton, EuiCallOut, EuiSearchBar, EuiSpacer, Query } from '@elastic/eui'; +import { SnapshotDeleteProvider } from '../../../../components'; +import { SnapshotDetails } from '../../../../../../common/types'; +import { getQueryFromListParams, SnapshotListParams, getListParams } from '../../../../lib'; + +const SEARCH_DEBOUNCE_VALUE_MS = 200; + +const onlyOneClauseMessage = i18n.translate( + 'xpack.snapshotRestore.snapshotList.searchBar.onlyOneClauseMessage', + { + defaultMessage: 'You can only use one clause in the search bar', + } +); +// for now limit the search bar to snapshot, repository and policyName queries +const searchSchema: SchemaType = { + strict: true, + fields: { + snapshot: { + type: 'string', + }, + repository: { + type: 'string', + }, + policyName: { + type: 'string', + }, + }, +}; + +interface Props { + listParams: SnapshotListParams; + setListParams: (listParams: SnapshotListParams) => void; + reload: () => void; + selectedItems: SnapshotDetails[]; + onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void; + repositories: string[]; +} + +export const SnapshotSearchBar: React.FunctionComponent = ({ + listParams, + setListParams, + reload, + selectedItems, + onSnapshotDeleted, + repositories, +}) => { + const [cachedListParams, setCachedListParams] = useState(listParams); + // send the request after the user has stopped typing + useDebounce( + () => { + setListParams(cachedListParams); + }, + SEARCH_DEBOUNCE_VALUE_MS, + [cachedListParams] + ); + + const deleteButton = selectedItems.length ? ( + + {( + deleteSnapshotPrompt: ( + ids: Array<{ snapshot: string; repository: string }>, + onSuccess?: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void + ) => void + ) => { + return ( + + deleteSnapshotPrompt( + selectedItems.map(({ snapshot, repository }) => ({ snapshot, repository })), + onSnapshotDeleted + ) + } + color="danger" + data-test-subj="srSnapshotListBulkDeleteActionButton" + > + + + ); + }} + + ) : ( + [] + ); + const searchFilters: SearchFilterConfig[] = [ + { + type: 'field_value_selection' as const, + field: 'repository', + name: i18n.translate('xpack.snapshotRestore.snapshotList.table.repositoryFilterLabel', { + defaultMessage: 'Repository', + }), + operator: 'exact', + multiSelect: false, + options: repositories.map((repository) => ({ + value: repository, + view: repository, + })), + }, + ]; + const reloadButton = ( + + + + ); + + const [query, setQuery] = useState(getQueryFromListParams(listParams)); + const [error, setError] = useState(null); + + const onSearchBarChange = (args: EuiSearchBarOnChangeArgs) => { + const { query: changedQuery, error: queryError } = args; + if (queryError) { + setError(queryError); + } else if (changedQuery) { + setError(null); + setQuery(changedQuery); + if (changedQuery.ast.clauses.length > 1) { + setError({ name: onlyOneClauseMessage, message: onlyOneClauseMessage }); + } else { + setCachedListParams(getListParams(listParams, changedQuery)); + } + } + }; + + return ( + <> + + + {error ? ( + <> + + } + /> + + + ) : null} + + ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/snapshot_table.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx similarity index 71% rename from x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/snapshot_table.tsx rename to x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx index 47f8d9b833e40..5db702fcbd963 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_table/snapshot_table.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx @@ -7,34 +7,28 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types'; + import { - EuiButton, - EuiInMemoryTable, EuiLink, - Query, EuiLoadingSpinner, EuiToolTip, EuiButtonIcon, + Criteria, + EuiBasicTable, } from '@elastic/eui'; - import { SnapshotDetails } from '../../../../../../common/types'; -import { UseRequestResponse } from '../../../../../shared_imports'; +import { UseRequestResponse, reactRouterNavigate } from '../../../../../shared_imports'; import { SNAPSHOT_STATE, UIM_SNAPSHOT_SHOW_DETAILS_CLICK } from '../../../../constants'; import { useServices } from '../../../../app_context'; -import { linkToRepository, linkToRestoreSnapshot } from '../../../../services/navigation'; +import { + linkToRepository, + linkToRestoreSnapshot, + linkToSnapshot as openSnapshotDetailsUrl, +} from '../../../../services/navigation'; +import { SnapshotListParams, SortDirection, SortField } from '../../../../lib'; import { DataPlaceholder, FormattedDateTime, SnapshotDeleteProvider } from '../../../../components'; - -import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; - -interface Props { - snapshots: SnapshotDetails[]; - repositories: string[]; - reload: UseRequestResponse['resendRequest']; - openSnapshotDetailsUrl: (repositoryName: string, snapshotId: string) => string; - repositoryFilter?: string; - policyFilter?: string; - onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void; -} +import { SnapshotSearchBar } from './snapshot_search_bar'; const getLastSuccessfulManagedSnapshot = ( snapshots: SnapshotDetails[] @@ -51,15 +45,28 @@ const getLastSuccessfulManagedSnapshot = ( return successfulSnapshots[0]; }; -export const SnapshotTable: React.FunctionComponent = ({ - snapshots, - repositories, - reload, - openSnapshotDetailsUrl, - onSnapshotDeleted, - repositoryFilter, - policyFilter, -}) => { +interface Props { + snapshots: SnapshotDetails[]; + repositories: string[]; + reload: UseRequestResponse['resendRequest']; + onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void; + listParams: SnapshotListParams; + setListParams: (listParams: SnapshotListParams) => void; + totalItemCount: number; + isLoading: boolean; +} + +export const SnapshotTable: React.FunctionComponent = (props: Props) => { + const { + snapshots, + repositories, + reload, + onSnapshotDeleted, + listParams, + setListParams, + totalItemCount, + isLoading, + } = props; const { i18n, uiMetricService, history } = useServices(); const [selectedItems, setSelectedItems] = useState([]); @@ -71,7 +78,7 @@ export const SnapshotTable: React.FunctionComponent = ({ name: i18n.translate('xpack.snapshotRestore.snapshotList.table.snapshotColumnTitle', { defaultMessage: 'Snapshot', }), - truncateText: true, + truncateText: false, sortable: true, render: (snapshotId: string, snapshot: SnapshotDetails) => ( = ({ name: i18n.translate('xpack.snapshotRestore.snapshotList.table.repositoryColumnTitle', { defaultMessage: 'Repository', }), - truncateText: true, + truncateText: false, sortable: true, render: (repositoryName: string) => ( = ({ name: i18n.translate('xpack.snapshotRestore.snapshotList.table.startTimeColumnTitle', { defaultMessage: 'Date created', }), - truncateText: true, + truncateText: false, sortable: true, render: (startTimeInMillis: number) => ( @@ -263,30 +270,20 @@ export const SnapshotTable: React.FunctionComponent = ({ }, ]; - // By default, we'll display the most recent snapshots at the top of the table. - const sorting = { + const sorting: EuiTableSortingType = { sort: { - field: 'startTimeInMillis', - direction: 'desc' as const, + field: listParams.sortField as keyof SnapshotDetails, + direction: listParams.sortDirection, }, }; const pagination = { - initialPageSize: 20, + pageIndex: listParams.pageIndex, + pageSize: listParams.pageSize, + totalItemCount, pageSizeOptions: [10, 20, 50], }; - const searchSchema = { - fields: { - repository: { - type: 'string', - }, - policyName: { - type: 'string', - }, - }, - }; - const selection = { onSelectionChange: (newSelectedItems: SnapshotDetails[]) => setSelectedItems(newSelectedItems), selectable: ({ snapshot }: SnapshotDetails) => @@ -306,103 +303,44 @@ export const SnapshotTable: React.FunctionComponent = ({ }, }; - const search = { - toolsLeft: selectedItems.length ? ( - - {( - deleteSnapshotPrompt: ( - ids: Array<{ snapshot: string; repository: string }>, - onSuccess?: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void - ) => void - ) => { - return ( - - deleteSnapshotPrompt( - selectedItems.map(({ snapshot, repository }) => ({ snapshot, repository })), - onSnapshotDeleted - ) - } - color="danger" - data-test-subj="srSnapshotListBulkDeleteActionButton" - > - - - ); - }} - - ) : undefined, - toolsRight: ( - - - - ), - box: { - incremental: true, - schema: searchSchema, - }, - filters: [ - { - type: 'field_value_selection' as const, - field: 'repository', - name: i18n.translate('xpack.snapshotRestore.snapshotList.table.repositoryFilterLabel', { - defaultMessage: 'Repository', - }), - multiSelect: false, - options: repositories.map((repository) => ({ - value: repository, - view: repository, - })), - }, - ], - defaultQuery: policyFilter - ? Query.parse(`policyName="${policyFilter}"`, { - schema: { - ...searchSchema, - strict: true, - }, - }) - : repositoryFilter - ? Query.parse(`repository="${repositoryFilter}"`, { - schema: { - ...searchSchema, - strict: true, - }, - }) - : '', - }; - return ( - ({ - 'data-test-subj': 'row', - })} - cellProps={() => ({ - 'data-test-subj': 'cell', - })} - data-test-subj="snapshotTable" - /> + <> + + ) => { + const { page: { index, size } = {}, sort: { field, direction } = {} } = criteria; + + setListParams({ + ...listParams, + sortField: (field as SortField) ?? listParams.sortField, + sortDirection: (direction as SortDirection) ?? listParams.sortDirection, + pageIndex: index ?? listParams.pageIndex, + pageSize: size ?? listParams.pageSize, + }); + }} + loading={isLoading} + isSelectable={true} + selection={selection} + pagination={pagination} + rowProps={() => ({ + 'data-test-subj': 'row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + data-test-subj="snapshotTable" + /> + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx index 92c03d1be936d..da7ec42f746a3 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx @@ -5,37 +5,26 @@ * 2.0. */ -import React, { Fragment, useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { parse } from 'query-string'; import { FormattedMessage } from '@kbn/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; -import { - EuiPageContent, - EuiButton, - EuiCallOut, - EuiLink, - EuiEmptyPrompt, - EuiSpacer, - EuiIcon, -} from '@elastic/eui'; +import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; -import { APP_SLM_CLUSTER_PRIVILEGES, SNAPSHOT_LIST_MAX_SIZE } from '../../../../../common'; -import { WithPrivileges, PageLoading, PageError, Error } from '../../../../shared_imports'; +import { PageLoading, PageError, Error, reactRouterNavigate } from '../../../../shared_imports'; import { BASE_PATH, UIM_SNAPSHOT_LIST_LOAD } from '../../../constants'; import { useLoadSnapshots } from '../../../services/http'; -import { - linkToRepositories, - linkToAddRepository, - linkToPolicies, - linkToAddPolicy, - linkToSnapshot, -} from '../../../services/navigation'; -import { useCore, useServices } from '../../../app_context'; -import { useDecodedParams } from '../../../lib'; -import { SnapshotDetails } from './snapshot_details'; -import { SnapshotTable } from './snapshot_table'; +import { linkToRepositories } from '../../../services/navigation'; +import { useServices } from '../../../app_context'; +import { useDecodedParams, SnapshotListParams, DEFAULT_SNAPSHOT_LIST_PARAMS } from '../../../lib'; -import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; +import { SnapshotDetails } from './snapshot_details'; +import { + SnapshotTable, + RepositoryEmptyPrompt, + SnapshotEmptyPrompt, + RepositoryError, +} from './components'; interface MatchParams { repositoryName?: string; @@ -47,22 +36,22 @@ export const SnapshotList: React.FunctionComponent { const { repositoryName, snapshotId } = useDecodedParams(); + const [listParams, setListParams] = useState(DEFAULT_SNAPSHOT_LIST_PARAMS); const { error, + isInitialRequest, isLoading, - data: { snapshots = [], repositories = [], policies = [], errors = {} }, + data: { + snapshots = [], + repositories = [], + policies = [], + errors = {}, + total: totalSnapshotsCount, + }, resendRequest: reload, - } = useLoadSnapshots(); + } = useLoadSnapshots(listParams); - const { uiMetricService, i18n } = useServices(); - const { docLinks } = useCore(); - - const openSnapshotDetailsUrl = ( - repositoryNameToOpen: string, - snapshotIdToOpen: string - ): string => { - return linkToSnapshot(repositoryNameToOpen, snapshotIdToOpen); - }; + const { uiMetricService } = useServices(); const closeSnapshotDetails = () => { history.push(`${BASE_PATH}/snapshots`); @@ -86,22 +75,32 @@ export const SnapshotList: React.FunctionComponent(undefined); - const [filteredPolicy, setFilteredPolicy] = useState(undefined); useEffect(() => { if (search) { const parsedParams = parse(search.replace(/^\?/, ''), { sort: false }); const { repository, policy } = parsedParams; - if (policy && policy !== filteredPolicy) { - setFilteredPolicy(String(policy)); + if (policy) { + setListParams((prev: SnapshotListParams) => ({ + ...prev, + searchField: 'policyName', + searchValue: String(policy), + searchMatch: 'must', + searchOperator: 'exact', + })); history.replace(`${BASE_PATH}/snapshots`); - } else if (repository && repository !== filteredRepository) { - setFilteredRepository(String(repository)); + } else if (repository) { + setListParams((prev: SnapshotListParams) => ({ + ...prev, + searchField: 'repository', + searchValue: String(repository), + searchMatch: 'must', + searchOperator: 'exact', + })); history.replace(`${BASE_PATH}/snapshots`); } } - }, [filteredPolicy, filteredRepository, history, search]); + }, [listParams, history, search]); // Track component loaded useEffect(() => { @@ -110,7 +109,8 @@ export const SnapshotList: React.FunctionComponent @@ -134,190 +134,11 @@ export const SnapshotList: React.FunctionComponent ); } else if (Object.keys(errors).length && repositories.length === 0) { - content = ( - - - - - } - body={ -

- - - - ), - }} - /> -

- } - /> -
- ); + content = ; } else if (repositories.length === 0) { - content = ( - - - - - } - body={ - <> -

- -

-

- - - -

- - } - data-test-subj="emptyPrompt" - /> -
- ); - } else if (snapshots.length === 0) { - content = ( - - - - - } - body={ - `cluster.${name}`)} - > - {({ hasPrivileges }) => - hasPrivileges ? ( - -

- - - - ), - }} - /> -

-

- {policies.length === 0 ? ( - - - - ) : ( - - - - )} -

-
- ) : ( - -

- -

-

- - {' '} - - -

-
- ) - } -
- } - data-test-subj="emptyPrompt" - /> -
- ); + content = ; + } else if (totalSnapshotsCount === 0 && !listParams.searchField && !isLoading) { + content = ; } else { const repositoryErrorsWarning = Object.keys(errors).length ? ( <> @@ -351,53 +172,19 @@ export const SnapshotList: React.FunctionComponent ) : null; - const maxSnapshotsWarning = snapshots.length === SNAPSHOT_LIST_MAX_SIZE && ( - <> - - - -
- ), - }} - /> -
- - - ); - content = (
{repositoryErrorsWarning} - {maxSnapshotsWarning} -
); diff --git a/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts b/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts index 3d64dc96958de..c02d0f053f783 100644 --- a/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts +++ b/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { API_BASE_PATH } from '../../../../common/constants'; +import { HttpFetchQuery } from 'kibana/public'; +import { API_BASE_PATH } from '../../../../common'; import { UIM_SNAPSHOT_DELETE, UIM_SNAPSHOT_DELETE_MANY } from '../../constants'; +import { SnapshotListParams } from '../../lib'; import { UiMetricService } from '../ui_metric'; import { sendRequest, useRequest } from './use_request'; @@ -18,11 +20,12 @@ export const setUiMetricServiceSnapshot = (_uiMetricService: UiMetricService) => }; // End hack -export const useLoadSnapshots = () => +export const useLoadSnapshots = (query: SnapshotListParams) => useRequest({ path: `${API_BASE_PATH}snapshots`, method: 'get', initialData: [], + query: query as unknown as HttpFetchQuery, }); export const useLoadSnapshot = (repositoryName: string, snapshotId: string) => diff --git a/x-pack/plugins/snapshot_restore/public/shared_imports.ts b/x-pack/plugins/snapshot_restore/public/shared_imports.ts index d1b9f37703c0c..a3cda90d26f2a 100644 --- a/x-pack/plugins/snapshot_restore/public/shared_imports.ts +++ b/x-pack/plugins/snapshot_restore/public/shared_imports.ts @@ -26,3 +26,5 @@ export { } from '../../../../src/plugins/es_ui_shared/public'; export { APP_WRAPPER_CLASS } from '../../../../src/core/public'; + +export { reactRouterNavigate } from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.test.ts b/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.test.ts new file mode 100644 index 0000000000000..d3e5c604d22ad --- /dev/null +++ b/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { getSnapshotSearchWildcard } from './get_snapshot_search_wildcard'; + +describe('getSnapshotSearchWildcard', () => { + it('exact match search converts to a wildcard without *', () => { + const searchParams = { + field: 'snapshot', + value: 'testSearch', + operator: 'exact', + match: 'must', + }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('testSearch'); + }); + + it('partial match search converts to a wildcard with *', () => { + const searchParams = { field: 'snapshot', value: 'testSearch', operator: 'eq', match: 'must' }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('*testSearch*'); + }); + + it('excluding search converts to "all, except" wildcard (exact match)', () => { + const searchParams = { + field: 'snapshot', + value: 'testSearch', + operator: 'exact', + match: 'must_not', + }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('*,-testSearch'); + }); + + it('excluding search converts to "all, except" wildcard (partial match)', () => { + const searchParams = { + field: 'snapshot', + value: 'testSearch', + operator: 'eq', + match: 'must_not', + }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('*,-*testSearch*'); + }); + + it('excluding search for policy name converts to "all,_none, except" wildcard', () => { + const searchParams = { + field: 'policyName', + value: 'testSearch', + operator: 'exact', + match: 'must_not', + }; + const wildcard = getSnapshotSearchWildcard(searchParams); + expect(wildcard).toEqual('*,_none,-testSearch'); + }); +}); diff --git a/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.ts b/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.ts new file mode 100644 index 0000000000000..df8926d785712 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +interface SearchParams { + field: string; + value: string; + match?: string; + operator?: string; +} + +export const getSnapshotSearchWildcard = ({ + field, + value, + match, + operator, +}: SearchParams): string => { + // if the operator is NOT for exact match, convert to *value* wildcard that matches any substring + value = operator === 'exact' ? value : `*${value}*`; + + // ES API new "-"("except") wildcard removes matching items from a list of already selected items + // To find all items not containing the search value, use "*,-{searchValue}" + // When searching for policy name, also add "_none" to find snapshots without a policy as well + const excludingWildcard = field === 'policyName' ? `*,_none,-${value}` : `*,-${value}`; + + return match === 'must_not' ? excludingWildcard : value; +}; diff --git a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.test.ts b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.test.ts index f71c5ec9ffc08..4ecd34a43adb9 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.test.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.test.ts @@ -51,6 +51,10 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => { const mockRequest: RequestMock = { method: 'get', path: addBasePath('snapshots'), + query: { + sortField: 'startTimeInMillis', + sortDirection: 'desc', + }, }; const mockSnapshotGetManagedRepositoryEsResponse = { diff --git a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts index 6838ae2700f3a..4de0c3011fed5 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -7,11 +7,36 @@ import { schema, TypeOf } from '@kbn/config-schema'; import type { SnapshotDetailsEs } from '../../../common/types'; -import { SNAPSHOT_LIST_MAX_SIZE } from '../../../common/constants'; import { deserializeSnapshotDetails } from '../../../common/lib'; import type { RouteDependencies } from '../../types'; import { getManagedRepositoryName } from '../../lib'; import { addBasePath } from '../helpers'; +import { snapshotListSchema } from './validate_schemas'; +import { getSnapshotSearchWildcard } from '../../lib/get_snapshot_search_wildcard'; + +const sortFieldToESParams = { + snapshot: 'name', + repository: 'repository', + indices: 'index_count', + startTimeInMillis: 'start_time', + durationInMillis: 'duration', + 'shards.total': 'shard_count', + 'shards.failed': 'failed_shard_count', +}; + +const isSearchingForNonExistentRepository = ( + repositories: string[], + value: string, + match?: string, + operator?: string +): boolean => { + // only check if searching for an exact match (repository=test) + if (match === 'must' && operator === 'exact') { + return !(repositories || []).includes(value); + } + // otherwise we will use a wildcard, so allow the request + return false; +}; export function registerSnapshotsRoutes({ router, @@ -20,9 +45,18 @@ export function registerSnapshotsRoutes({ }: RouteDependencies) { // GET all snapshots router.get( - { path: addBasePath('snapshots'), validate: false }, + { path: addBasePath('snapshots'), validate: { query: snapshotListSchema } }, license.guardApiRoute(async (ctx, req, res) => { const { client: clusterClient } = ctx.core.elasticsearch; + const sortField = + sortFieldToESParams[(req.query as TypeOf).sortField]; + const sortDirection = (req.query as TypeOf).sortDirection; + const pageIndex = (req.query as TypeOf).pageIndex; + const pageSize = (req.query as TypeOf).pageSize; + const searchField = (req.query as TypeOf).searchField; + const searchValue = (req.query as TypeOf).searchValue; + const searchMatch = (req.query as TypeOf).searchMatch; + const searchOperator = (req.query as TypeOf).searchOperator; const managedRepository = await getManagedRepositoryName(clusterClient.asCurrentUser); @@ -55,18 +89,60 @@ export function registerSnapshotsRoutes({ return handleEsError({ error: e, response: res }); } + // if the search is for a repository name with exact match (repository=test) + // and that repository doesn't exist, ES request throws an error + // that is why we return an empty snapshots array instead of sending an ES request + if ( + searchField === 'repository' && + isSearchingForNonExistentRepository(repositories, searchValue!, searchMatch, searchOperator) + ) { + return res.ok({ + body: { + snapshots: [], + policies, + repositories, + errors: [], + total: 0, + }, + }); + } try { // If any of these repositories 504 they will cost the request significant time. const { body: fetchedSnapshots } = await clusterClient.asCurrentUser.snapshot.get({ - repository: '_all', - snapshot: '_all', + repository: + searchField === 'repository' + ? getSnapshotSearchWildcard({ + field: searchField, + value: searchValue!, + match: searchMatch, + operator: searchOperator, + }) + : '_all', ignore_unavailable: true, // Allow request to succeed even if some snapshots are unavailable. - // @ts-expect-error @elastic/elasticsearch "desc" is a new param - order: 'desc', - // TODO We are temporarily hard-coding the maximum number of snapshots returned - // in order to prevent an unusable UI for users with large number of snapshots - // In the near future, this will be resolved with server-side pagination - size: SNAPSHOT_LIST_MAX_SIZE, + snapshot: + searchField === 'snapshot' + ? getSnapshotSearchWildcard({ + field: searchField, + value: searchValue!, + match: searchMatch, + operator: searchOperator, + }) + : '_all', + // @ts-expect-error @elastic/elasticsearch new API params + // https://github.com/elastic/elasticsearch-specification/issues/845 + slm_policy_filter: + searchField === 'policyName' + ? getSnapshotSearchWildcard({ + field: searchField, + value: searchValue!, + match: searchMatch, + operator: searchOperator, + }) + : '*,_none', + order: sortDirection, + sort: sortField, + size: pageSize, + offset: pageIndex * pageSize, }); // Decorate each snapshot with the repository with which it's associated. @@ -79,8 +155,10 @@ export function registerSnapshotsRoutes({ snapshots: snapshots || [], policies, repositories, - // @ts-expect-error @elastic/elasticsearch "failures" is a new field in the response + // @ts-expect-error @elastic/elasticsearch https://github.com/elastic/elasticsearch-specification/issues/845 errors: fetchedSnapshots?.failures, + // @ts-expect-error @elastic/elasticsearch "total" is a new field in the response + total: fetchedSnapshots?.total, }, }); } catch (e) { @@ -170,7 +248,7 @@ export function registerSnapshotsRoutes({ const snapshots = req.body; try { - // We intentially perform deletion requests sequentially (blocking) instead of in parallel (non-blocking) + // We intentionally perform deletion requests sequentially (blocking) instead of in parallel (non-blocking) // because there can only be one snapshot deletion task performed at a time (ES restriction). for (let i = 0; i < snapshots.length; i++) { const { snapshot, repository } = snapshots[i]; diff --git a/x-pack/plugins/snapshot_restore/server/routes/api/validate_schemas.ts b/x-pack/plugins/snapshot_restore/server/routes/api/validate_schemas.ts index af31466c2cefe..e93ee2b3d78ca 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/validate_schemas.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/validate_schemas.ts @@ -26,6 +26,31 @@ const snapshotRetentionSchema = schema.object({ minCount: schema.maybe(schema.oneOf([schema.number(), schema.literal('')])), }); +export const snapshotListSchema = schema.object({ + sortField: schema.oneOf([ + schema.literal('snapshot'), + schema.literal('repository'), + schema.literal('indices'), + schema.literal('durationInMillis'), + schema.literal('startTimeInMillis'), + schema.literal('shards.total'), + schema.literal('shards.failed'), + ]), + sortDirection: schema.oneOf([schema.literal('desc'), schema.literal('asc')]), + pageIndex: schema.number(), + pageSize: schema.number(), + searchField: schema.maybe( + schema.oneOf([ + schema.literal('snapshot'), + schema.literal('repository'), + schema.literal('policyName'), + ]) + ), + searchValue: schema.maybe(schema.string()), + searchMatch: schema.maybe(schema.oneOf([schema.literal('must'), schema.literal('must_not')])), + searchOperator: schema.maybe(schema.oneOf([schema.literal('eq'), schema.literal('exact')])), +}); + export const policySchema = schema.object({ name: schema.string(), snapshotName: schema.string(), diff --git a/x-pack/plugins/snapshot_restore/server/routes/helpers.ts b/x-pack/plugins/snapshot_restore/server/routes/helpers.ts index 1f49d2f3cabfb..e73db4d992ff2 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/helpers.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/helpers.ts @@ -5,6 +5,6 @@ * 2.0. */ -import { API_BASE_PATH } from '../../common/constants'; +import { API_BASE_PATH } from '../../common'; export const addBasePath = (uri: string): string => API_BASE_PATH + uri; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 41cc7825bcf36..fc89eb25bfc91 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23904,9 +23904,6 @@ "xpack.snapshotRestore.snapshotList.table.snapshotColumnTitle": "スナップショット", "xpack.snapshotRestore.snapshotList.table.startTimeColumnTitle": "日付が作成されました", "xpack.snapshotRestore.snapshots.breadcrumbTitle": "スナップショット", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedDescription": "表示可能なスナップショットの最大数に達しました。スナップショットをすべて表示するには、{docLink}を使用してください。", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedDocLinkText": "Elasticsearch API", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedTitle": "スナップショットの一覧を表示できません。", "xpack.snapshotRestore.snapshotState.completeLabel": "スナップショット完了", "xpack.snapshotRestore.snapshotState.failedLabel": "スナップショット失敗", "xpack.snapshotRestore.snapshotState.incompatibleLabel": "互換性のないバージョン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 54811936957ab..a85cc7b60b00b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -24306,9 +24306,6 @@ "xpack.snapshotRestore.snapshotList.table.snapshotColumnTitle": "快照", "xpack.snapshotRestore.snapshotList.table.startTimeColumnTitle": "创建日期", "xpack.snapshotRestore.snapshots.breadcrumbTitle": "快照", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedDescription": "已达到最大可查看快照数目。要查看您的所有快照,请使用{docLink}。", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedDocLinkText": "Elasticsearch API", - "xpack.snapshotRestore.snapshotsList.maxSnapshotsDisplayedTitle": "无法显示快照的完整列表", "xpack.snapshotRestore.snapshotState.completeLabel": "快照完成", "xpack.snapshotRestore.snapshotState.failedLabel": "快照失败", "xpack.snapshotRestore.snapshotState.incompatibleLabel": "不兼容版本", diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/index.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/index.ts index 4d39ff1494f89..db5dbc9735e66 100644 --- a/x-pack/test/api_integration/apis/management/snapshot_restore/index.ts +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Snapshot and Restore', () => { - loadTestFile(require.resolve('./snapshot_restore')); + loadTestFile(require.resolve('./policies')); + loadTestFile(require.resolve('./snapshots')); }); } diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/lib/elasticsearch.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/lib/elasticsearch.ts index 9b4d39a3b10b3..a59c90fe29132 100644 --- a/x-pack/test/api_integration/apis/management/snapshot_restore/lib/elasticsearch.ts +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/lib/elasticsearch.ts @@ -7,9 +7,10 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; -interface SlmPolicy { +export interface SlmPolicy { + policyName: string; + // snapshot name name: string; - snapshotName: string; schedule: string; repository: string; isManagedPolicy: boolean; @@ -29,23 +30,22 @@ interface SlmPolicy { } /** - * Helpers to create and delete SLM policies on the Elasticsearch instance + * Helpers to create and delete SLM policies, repositories and snapshots on the Elasticsearch instance * during our tests. - * @param {ElasticsearchClient} es The Elasticsearch client instance */ export const registerEsHelpers = (getService: FtrProviderContext['getService']) => { let policiesCreated: string[] = []; const es = getService('es'); - const createRepository = (repoName: string) => { + const createRepository = (repoName: string, repoPath?: string) => { return es.snapshot .createRepository({ repository: repoName, body: { type: 'fs', settings: { - location: '/tmp/', + location: repoPath ?? '/tmp/repo', }, }, verify: false, @@ -55,12 +55,12 @@ export const registerEsHelpers = (getService: FtrProviderContext['getService']) const createPolicy = (policy: SlmPolicy, cachePolicy?: boolean) => { if (cachePolicy) { - policiesCreated.push(policy.name); + policiesCreated.push(policy.policyName); } return es.slm .putLifecycle({ - policy_id: policy.name, + policy_id: policy.policyName, // TODO: bring {@link SlmPolicy} in line with {@link PutSnapshotLifecycleRequest['body']} // @ts-expect-error body: policy, @@ -90,11 +90,34 @@ export const registerEsHelpers = (getService: FtrProviderContext['getService']) console.log(`[Cleanup error] Error deleting ES resources: ${err.message}`); }); + const executePolicy = (policyName: string) => { + return es.slm.executeLifecycle({ policy_id: policyName }).then(({ body }) => body); + }; + + const createSnapshot = (snapshotName: string, repositoryName: string) => { + return es.snapshot + .create({ snapshot: snapshotName, repository: repositoryName }) + .then(({ body }) => body); + }; + + const deleteSnapshots = (repositoryName: string) => { + es.snapshot + .delete({ repository: repositoryName, snapshot: '*' }) + .then(() => {}) + .catch((err) => { + // eslint-disable-next-line no-console + console.log(`[Cleanup error] Error deleting snapshots: ${err.message}`); + }); + }; + return { createRepository, createPolicy, deletePolicy, cleanupPolicies, getPolicy, + executePolicy, + createSnapshot, + deleteSnapshots, }; }; diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/lib/index.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/lib/index.ts index 27a4d9c59cff0..a9721c5856598 100644 --- a/x-pack/test/api_integration/apis/management/snapshot_restore/lib/index.ts +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/lib/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { registerEsHelpers } from './elasticsearch'; +export { registerEsHelpers, SlmPolicy } from './elasticsearch'; diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/snapshot_restore.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/policies.ts similarity index 95% rename from x-pack/test/api_integration/apis/management/snapshot_restore/snapshot_restore.ts rename to x-pack/test/api_integration/apis/management/snapshot_restore/policies.ts index a6ac2d057c84e..e0734680887d2 100644 --- a/x-pack/test/api_integration/apis/management/snapshot_restore/snapshot_restore.ts +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/policies.ts @@ -19,7 +19,7 @@ export default function ({ getService }: FtrProviderContext) { const { createRepository, createPolicy, deletePolicy, cleanupPolicies, getPolicy } = registerEsHelpers(getService); - describe('Snapshot Lifecycle Management', function () { + describe('SLM policies', function () { this.tags(['skipCloud']); // file system repositories are not supported in cloud before(async () => { @@ -134,9 +134,8 @@ export default function ({ getService }: FtrProviderContext) { describe('Update', () => { const POLICY_NAME = 'test_update_policy'; + const SNAPSHOT_NAME = 'my_snapshot'; const POLICY = { - name: POLICY_NAME, - snapshotName: 'my_snapshot', schedule: '0 30 1 * * ?', repository: REPO_NAME, config: { @@ -159,7 +158,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { // Create SLM policy that can be used to test PUT request try { - await createPolicy(POLICY, true); + await createPolicy({ ...POLICY, policyName: POLICY_NAME, name: SNAPSHOT_NAME }, true); } catch (err) { // eslint-disable-next-line no-console console.log('[Setup error] Error creating policy'); @@ -175,6 +174,8 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ ...POLICY, + name: POLICY_NAME, + snapshotName: SNAPSHOT_NAME, schedule: '0 0 0 ? * 7', }) .expect(200); @@ -212,7 +213,7 @@ export default function ({ getService }: FtrProviderContext) { const { body } = await supertest .put(uri) .set('kbn-xsrf', 'xxx') - .send(requiredFields) + .send({ ...requiredFields, name: POLICY_NAME, snapshotName: SNAPSHOT_NAME }) .expect(200); expect(body).to.eql({ diff --git a/x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts b/x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts new file mode 100644 index 0000000000000..1677013dd5e7e --- /dev/null +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts @@ -0,0 +1,729 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { registerEsHelpers, SlmPolicy } from './lib'; +import { SnapshotDetails } from '../../../../../plugins/snapshot_restore/common/types'; + +const REPO_NAME_1 = 'test_repo_1'; +const REPO_NAME_2 = 'test_another_repo_2'; +const REPO_PATH_1 = '/tmp/repo_1'; +const REPO_PATH_2 = '/tmp/repo_2'; +// SLM policies to test policyName filter +const POLICY_NAME_1 = 'test_policy_1'; +const POLICY_NAME_2 = 'test_another_policy_2'; +const POLICY_SNAPSHOT_NAME_1 = 'backup_snapshot'; +const POLICY_SNAPSHOT_NAME_2 = 'a_snapshot'; +// snapshots created without SLM policies +const BATCH_SIZE_1 = 3; +const BATCH_SIZE_2 = 5; +const BATCH_SNAPSHOT_NAME_1 = 'another_snapshot'; +const BATCH_SNAPSHOT_NAME_2 = 'xyz_another_snapshot'; +// total count consists of both batches' sizes + 2 snapshots created by 2 SLM policies (one each) +const SNAPSHOT_COUNT = BATCH_SIZE_1 + BATCH_SIZE_2 + 2; +// API defaults used in the UI +const PAGE_INDEX = 0; +const PAGE_SIZE = 20; +const SORT_FIELD = 'startTimeInMillis'; +const SORT_DIRECTION = 'desc'; + +interface ApiParams { + pageIndex?: number; + pageSize?: number; + + sortField?: string; + sortDirection?: string; + + searchField?: string; + searchValue?: string; + searchMatch?: string; + searchOperator?: string; +} +const getApiPath = ({ + pageIndex, + pageSize, + sortField, + sortDirection, + searchField, + searchValue, + searchMatch, + searchOperator, +}: ApiParams): string => { + let path = `/api/snapshot_restore/snapshots?sortField=${sortField ?? SORT_FIELD}&sortDirection=${ + sortDirection ?? SORT_DIRECTION + }&pageIndex=${pageIndex ?? PAGE_INDEX}&pageSize=${pageSize ?? PAGE_SIZE}`; + // all 4 parameters should be used at the same time to configure the correct search request + if (searchField && searchValue && searchMatch && searchOperator) { + path = `${path}&searchField=${searchField}&searchValue=${searchValue}&searchMatch=${searchMatch}&searchOperator=${searchOperator}`; + } + return path; +}; +const getPolicyBody = (policy: Partial): SlmPolicy => { + return { + policyName: 'default_policy', + name: 'default_snapshot', + schedule: '0 30 1 * * ?', + repository: 'default_repo', + isManagedPolicy: false, + config: { + indices: ['default_index'], + ignoreUnavailable: true, + }, + ...policy, + }; +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const { + createSnapshot, + createRepository, + createPolicy, + executePolicy, + cleanupPolicies, + deleteSnapshots, + } = registerEsHelpers(getService); + + describe('Snapshots', function () { + this.tags(['skipCloud']); // file system repositories are not supported in cloud + + // names of snapshots created by SLM policies have random suffixes, save full names for tests + let snapshotName1: string; + let snapshotName2: string; + + before(async () => { + /* + * This setup creates following repos, SLM policies and snapshots: + * Repo 1 "test_repo_1" with 5 snapshots + * "backup_snapshot..." (created by SLM policy "test_policy_1") + * "a_snapshot..." (created by SLM policy "test_another_policy_2") + * "another_snapshot_0" to "another_snapshot_2" (no SLM policy) + * + * Repo 2 "test_another_repo_2" with 5 snapshots + * "xyz_another_snapshot_0" to "xyz_another_snapshot_4" (no SLM policy) + */ + try { + await createRepository(REPO_NAME_1, REPO_PATH_1); + await createRepository(REPO_NAME_2, REPO_PATH_2); + await createPolicy( + getPolicyBody({ + policyName: POLICY_NAME_1, + repository: REPO_NAME_1, + name: POLICY_SNAPSHOT_NAME_1, + }), + true + ); + await createPolicy( + getPolicyBody({ + policyName: POLICY_NAME_2, + repository: REPO_NAME_1, + name: POLICY_SNAPSHOT_NAME_2, + }), + true + ); + ({ snapshot_name: snapshotName1 } = await executePolicy(POLICY_NAME_1)); + // a short timeout to let the 1st snapshot start, otherwise the sorting by start time might be flaky + await new Promise((resolve) => setTimeout(resolve, 2000)); + ({ snapshot_name: snapshotName2 } = await executePolicy(POLICY_NAME_2)); + for (let i = 0; i < BATCH_SIZE_1; i++) { + await createSnapshot(`${BATCH_SNAPSHOT_NAME_1}_${i}`, REPO_NAME_1); + } + for (let i = 0; i < BATCH_SIZE_2; i++) { + await createSnapshot(`${BATCH_SNAPSHOT_NAME_2}_${i}`, REPO_NAME_2); + } + } catch (err) { + // eslint-disable-next-line no-console + console.log('[Setup error] Error creating snapshots'); + throw err; + } + }); + + after(async () => { + await cleanupPolicies(); + await deleteSnapshots(REPO_NAME_1); + await deleteSnapshots(REPO_NAME_2); + }); + + describe('pagination', () => { + it('returns pageSize number of snapshots', async () => { + const pageSize = 7; + const { + body: { total, snapshots }, + } = await supertest + .get( + getApiPath({ + pageSize, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + expect(total).to.eql(SNAPSHOT_COUNT); + expect(snapshots.length).to.eql(pageSize); + }); + + it('returns next page of snapshots', async () => { + const pageSize = 3; + let pageIndex = 0; + const { + body: { snapshots: firstPageSnapshots }, + } = await supertest + .get( + getApiPath({ + pageIndex, + pageSize, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + const firstPageSnapshotName = firstPageSnapshots[0].snapshot; + expect(firstPageSnapshots.length).to.eql(pageSize); + + pageIndex = 1; + const { + body: { snapshots: secondPageSnapshots }, + } = await supertest + .get( + getApiPath({ + pageIndex, + pageSize, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + const secondPageSnapshotName = secondPageSnapshots[0].snapshot; + expect(secondPageSnapshots.length).to.eql(pageSize); + expect(secondPageSnapshotName).to.not.eql(firstPageSnapshotName); + }); + }); + + describe('sorting', () => { + it('sorts by snapshot name (asc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'snapshot', + sortDirection: 'asc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + /* + * snapshots name in asc order: + * "a_snapshot...", "another_snapshot...", "backup_snapshot...", "xyz_another_snapshot..." + */ + const snapshotName = snapshots[0].snapshot; + // snapshotName2 is "a_snapshot..." + expect(snapshotName).to.eql(snapshotName2); + }); + + it('sorts by snapshot name (desc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'snapshot', + sortDirection: 'desc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + /* + * snapshots name in desc order: + * "xyz_another_snapshot...", "backup_snapshot...", "another_snapshot...", "a_snapshot..." + */ + const snapshotName = snapshots[0].snapshot; + expect(snapshotName).to.eql('xyz_another_snapshot_4'); + }); + + it('sorts by repository name (asc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'repository', + sortDirection: 'asc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + // repositories in asc order: "test_another_repo_2", "test_repo_1" + const repositoryName = snapshots[0].repository; + expect(repositoryName).to.eql(REPO_NAME_2); // "test_another_repo_2" + }); + + it('sorts by repository name (desc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'repository', + sortDirection: 'desc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + // repositories in desc order: "test_repo_1", "test_another_repo_2" + const repositoryName = snapshots[0].repository; + expect(repositoryName).to.eql(REPO_NAME_1); // "test_repo_1" + }); + + it('sorts by startTimeInMillis (asc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'startTimeInMillis', + sortDirection: 'asc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + const snapshotName = snapshots[0].snapshot; + // the 1st snapshot that was created during this test setup + expect(snapshotName).to.eql(snapshotName1); + }); + + it('sorts by startTimeInMillis (desc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'startTimeInMillis', + sortDirection: 'desc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + const snapshotName = snapshots[0].snapshot; + // the last snapshot that was created during this test setup + expect(snapshotName).to.eql('xyz_another_snapshot_4'); + }); + + // these properties are only tested as being accepted by the API + const sortFields = ['indices', 'durationInMillis', 'shards.total', 'shards.failed']; + sortFields.forEach((sortField: string) => { + it(`allows sorting by ${sortField} (asc)`, async () => { + await supertest + .get( + getApiPath({ + sortField, + sortDirection: 'asc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + }); + + it(`allows sorting by ${sortField} (desc)`, async () => { + await supertest + .get( + getApiPath({ + sortField, + sortDirection: 'desc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + }); + }); + }); + + describe('search', () => { + describe('snapshot name', () => { + it('exact match', async () => { + // list snapshots with the name "another_snapshot_2" + const searchField = 'snapshot'; + const searchValue = 'another_snapshot_2'; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(1); + expect(snapshots[0].snapshot).to.eql('another_snapshot_2'); + }); + + it('partial match', async () => { + // list snapshots with the name containing with "another" + const searchField = 'snapshot'; + const searchValue = 'another'; + const searchMatch = 'must'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // both batches created snapshots containing "another" in the name + expect(snapshots.length).to.eql(BATCH_SIZE_1 + BATCH_SIZE_2); + const snapshotNamesContainSearch = snapshots.every((snapshot: SnapshotDetails) => + snapshot.snapshot.includes('another') + ); + expect(snapshotNamesContainSearch).to.eql(true); + }); + + it('excluding search with exact match', async () => { + // list snapshots with the name not "another_snapshot_2" + const searchField = 'snapshot'; + const searchValue = 'another_snapshot_2'; + const searchMatch = 'must_not'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(SNAPSHOT_COUNT - 1); + const snapshotIsExcluded = snapshots.every( + (snapshot: SnapshotDetails) => snapshot.snapshot !== 'another_snapshot_2' + ); + expect(snapshotIsExcluded).to.eql(true); + }); + + it('excluding search with partial match', async () => { + // list snapshots with the name not starting with "another" + const searchField = 'snapshot'; + const searchValue = 'another'; + const searchMatch = 'must_not'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // both batches created snapshots with names containing "another" + expect(snapshots.length).to.eql(SNAPSHOT_COUNT - BATCH_SIZE_1 - BATCH_SIZE_2); + const snapshotsAreExcluded = snapshots.every( + (snapshot: SnapshotDetails) => !snapshot.snapshot.includes('another') + ); + expect(snapshotsAreExcluded).to.eql(true); + }); + }); + + describe('repository name', () => { + it('search for non-existent repository returns an empty snapshot array', async () => { + // search for non-existent repository + const searchField = 'repository'; + const searchValue = 'non-existent'; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(0); + }); + + it('exact match', async () => { + // list snapshots from repository "test_repo_1" + const searchField = 'repository'; + const searchValue = REPO_NAME_1; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // repo 1 contains snapshots from batch 1 and 2 snapshots created by 2 SLM policies + expect(snapshots.length).to.eql(BATCH_SIZE_1 + 2); + const repositoryNameMatches = snapshots.every( + (snapshot: SnapshotDetails) => snapshot.repository === REPO_NAME_1 + ); + expect(repositoryNameMatches).to.eql(true); + }); + + it('partial match', async () => { + // list snapshots from repository with the name containing "another" + // i.e. snapshots from repo 2 + const searchField = 'repository'; + const searchValue = 'another'; + const searchMatch = 'must'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // repo 2 only contains snapshots created by batch 2 + expect(snapshots.length).to.eql(BATCH_SIZE_2); + const repositoryNameMatches = snapshots.every((snapshot: SnapshotDetails) => + snapshot.repository.includes('another') + ); + expect(repositoryNameMatches).to.eql(true); + }); + + it('excluding search with exact match', async () => { + // list snapshots from repositories with the name not "test_repo_1" + const searchField = 'repository'; + const searchValue = REPO_NAME_1; + const searchMatch = 'must_not'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // snapshots not in repo 1 are only snapshots created in batch 2 + expect(snapshots.length).to.eql(BATCH_SIZE_2); + const repositoryNameMatches = snapshots.every( + (snapshot: SnapshotDetails) => snapshot.repository !== REPO_NAME_1 + ); + expect(repositoryNameMatches).to.eql(true); + }); + + it('excluding search with partial match', async () => { + // list snapshots from repository with the name not containing "test" + const searchField = 'repository'; + const searchValue = 'test'; + const searchMatch = 'must_not'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(0); + }); + }); + + describe('policy name', () => { + it('search for non-existent policy returns an empty snapshot array', async () => { + // search for non-existent policy + const searchField = 'policyName'; + const searchValue = 'non-existent'; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(0); + }); + + it('exact match', async () => { + // list snapshots created by the policy "test_policy_1" + const searchField = 'policyName'; + const searchValue = POLICY_NAME_1; + const searchMatch = 'must'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(1); + expect(snapshots[0].policyName).to.eql(POLICY_NAME_1); + }); + + it('partial match', async () => { + // list snapshots created by the policy with the name containing "another" + const searchField = 'policyName'; + const searchValue = 'another'; + const searchMatch = 'must'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // 1 snapshot was created by the policy "test_another_policy_2" + expect(snapshots.length).to.eql(1); + const policyNameMatches = snapshots.every((snapshot: SnapshotDetails) => + snapshot.policyName!.includes('another') + ); + expect(policyNameMatches).to.eql(true); + }); + + it('excluding search with exact match', async () => { + // list snapshots created by the policy with the name not "test_policy_1" + const searchField = 'policyName'; + const searchValue = POLICY_NAME_1; + const searchMatch = 'must_not'; + const searchOperator = 'exact'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // only 1 snapshot was created by policy 1 + // search results should also contain snapshots without SLM policy + expect(snapshots.length).to.eql(SNAPSHOT_COUNT - 1); + const snapshotsExcluded = snapshots.every( + (snapshot: SnapshotDetails) => (snapshot.policyName ?? '') !== POLICY_NAME_1 + ); + expect(snapshotsExcluded).to.eql(true); + }); + + it('excluding search with partial match', async () => { + // list snapshots created by the policy with the name not containing "another" + const searchField = 'policyName'; + const searchValue = 'another'; + const searchMatch = 'must_not'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + // only 1 snapshot was created by SLM policy containing "another" in the name + // search results should also contain snapshots without SLM policy + expect(snapshots.length).to.eql(SNAPSHOT_COUNT - 1); + const snapshotsExcluded = snapshots.every( + (snapshot: SnapshotDetails) => !(snapshot.policyName ?? '').includes('another') + ); + expect(snapshotsExcluded).to.eql(true); + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/config.ts b/x-pack/test/api_integration/config.ts index 3690f661c621c..678f7a0d3d929 100644 --- a/x-pack/test/api_integration/config.ts +++ b/x-pack/test/api_integration/config.ts @@ -41,6 +41,7 @@ export async function getApiIntegrationConfig({ readConfigFile }: FtrConfigProvi serverArgs: [ ...xPackFunctionalTestsConfig.get('esTestCluster.serverArgs'), 'node.attr.name=apiIntegrationTestNode', + 'path.repo=/tmp/repo,/tmp/repo_1,/tmp/repo_2', ], }, };