From 83e9c7afdf18a1bd338da3497dfa8370e810e6ca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yulia=20=C4=8Cech?=
<6585477+yuliacech@users.noreply.github.com>
Date: Mon, 18 Oct 2021 20:55:57 +0200
Subject: [PATCH] [Snapshot and Restore] Server side snapshots pagination
(#110266)
* [Snapshot % Restore] Added server side pagination and sorting to get snapshots route, refactored snapshots table to use EuiBasicTable with controlled pagination instead of EuiInMemoryTable
* [Snapshot & Restore] Added server side sorting by shards and failed shards counts
* [Snapshot & Restore] Fixed i18n errors
* [Snapshot & Restore] Added server side sorting by repository
* [Snapshot & Restore] Implemented server side search request for snapshot, repository and policy name
* [Snapshot & Restore] Fixed eslint errors
* [Snapshot & Restore] Removed uncommented code
* [Snapshot & Restore] Fixed pagination/search bug
* [Snapshot & Restore] Fixed pagination/search bug
* [Snapshot & Restore] Fixed text truncate bug
* [Snapshot & Restore] Fixed non existent repository search error
* Update x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx
Co-authored-by: CJ Cenizal
* Update x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_empty_prompt.tsx
Co-authored-by: James Rodewig <40268737+jrodewig@users.noreply.github.com>
* [Snapshot & Restore] Fixed missing i18n and no snapshots callout
* [Snapshot & Restore] Moved "getSnapshotSearchWildcard" to a separate file and added unit tests
* [Snapshot & Restore] Added api integration tests for "get snapshots" endpoint (pagination, sorting, search)
* [Snapshot & Restore] Renamed SnapshotSearchOptions/SnapshotTableOptions to -Params and added the link to the specs issue
* [Snapshot & Restore] Fixed search wildcard to also match string in the middle of the value, not only starting with the string. Also updated the tests following the code review suggestions to make them more readable.
* [Snapshot & Restore] Added incremental search back to snapshots list and a debounce of 500ms
* [Snapshot & Restore] Updated snapshot search debounce value and extracted it into a constant
* [Snapshot & Restore] Renamed debounceValue to cachedListParams and added a comment why debounce is used
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: CJ Cenizal
Co-authored-by: James Rodewig <40268737+jrodewig@users.noreply.github.com>
---
.../helpers/http_requests.ts | 4 +-
.../__jest__/client_integration/home.test.ts | 14 +-
.../snapshot_restore/common/constants.ts | 6 -
.../public/application/lib/index.ts | 9 +
.../application/lib/snapshot_list_params.ts | 88 +++
.../{snapshot_table => components}/index.ts | 4 +
.../components/repository_empty_prompt.tsx | 62 ++
.../components/repository_error.tsx | 51 ++
.../components/snapshot_empty_prompt.tsx | 123 +++
.../components/snapshot_search_bar.tsx | 178 +++++
.../snapshot_table.tsx | 224 ++----
.../home/snapshot_list/snapshot_list.tsx | 315 ++------
.../services/http/snapshot_requests.ts | 7 +-
.../snapshot_restore/public/shared_imports.ts | 2 +
.../lib/get_snapshot_search_wildcard.test.ts | 60 ++
.../lib/get_snapshot_search_wildcard.ts | 30 +
.../server/routes/api/snapshots.test.ts | 4 +
.../server/routes/api/snapshots.ts | 102 ++-
.../server/routes/api/validate_schemas.ts | 25 +
.../snapshot_restore/server/routes/helpers.ts | 2 +-
.../translations/translations/ja-JP.json | 3 -
.../translations/translations/zh-CN.json | 3 -
.../apis/management/snapshot_restore/index.ts | 3 +-
.../snapshot_restore/lib/elasticsearch.ts | 39 +-
.../management/snapshot_restore/lib/index.ts | 2 +-
.../{snapshot_restore.ts => policies.ts} | 11 +-
.../management/snapshot_restore/snapshots.ts | 729 ++++++++++++++++++
x-pack/test/api_integration/config.ts | 1 +
28 files changed, 1640 insertions(+), 461 deletions(-)
create mode 100644 x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts
rename x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/{snapshot_table => components}/index.ts (55%)
create mode 100644 x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_empty_prompt.tsx
create mode 100644 x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/repository_error.tsx
create mode 100644 x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_empty_prompt.tsx
create mode 100644 x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx
rename x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/{snapshot_table => components}/snapshot_table.tsx (71%)
create mode 100644 x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.test.ts
create mode 100644 x-pack/plugins/snapshot_restore/server/lib/get_snapshot_search_wildcard.ts
rename x-pack/test/api_integration/apis/management/snapshot_restore/{snapshot_restore.ts => policies.ts} (95%)
create mode 100644 x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts
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 c941cbd9ddf80..3df5e4ee6c48a 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -23900,9 +23900,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 e9e9f02c8fe99..d9af3edb8101d 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -24302,9 +24302,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',
],
},
};