From 2c2065f4b51df6e1464b07f20980312d231054f6 Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Thu, 26 Aug 2021 16:42:25 +0200 Subject: [PATCH 01/21] [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/common/constants.ts | 6 -- .../snapshot_restore/common/lib/index.ts | 7 ++ .../common/lib/snapshot_table_options.ts | 34 ++++++++++ .../home/snapshot_list/snapshot_list.tsx | 67 +++++-------------- .../snapshot_table/snapshot_table.tsx | 53 +++++++++++---- .../services/http/snapshot_requests.ts | 7 +- .../server/routes/api/snapshots.ts | 32 ++++++--- 7 files changed, 126 insertions(+), 80 deletions(-) create mode 100644 x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts 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/common/lib/index.ts b/x-pack/plugins/snapshot_restore/common/lib/index.ts index fc8015c5b807b..6128b59f3a247 100644 --- a/x-pack/plugins/snapshot_restore/common/lib/index.ts +++ b/x-pack/plugins/snapshot_restore/common/lib/index.ts @@ -17,3 +17,10 @@ export { export { deserializePolicy, serializePolicy } from './policy_serialization'; export { csvToArray } from './utils'; export { isDataStreamBackingIndex } from './is_data_stream_backing_index'; +export { + SnapshotTableOptions, + SortField, + SortDirection, + SNAPSHOT_DEFAULT_TABLE_OPTIONS, + convertSortFieldToES, +} from './snapshot_table_options'; diff --git a/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts b/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts new file mode 100644 index 0000000000000..678442f6c7ce0 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Direction } from '@elastic/eui'; + +export type SortField = 'snapshot' | 'indices' | 'startTimeInMillis' | 'durationInMillis'; +export type SortDirection = Direction; +export interface SnapshotTableOptions { + sortField: SortField; + sortDirection: SortDirection; + pageIndex: number; + pageSize: number; +} + +// By default, we'll display the most recent snapshots at the top of the table. +export const SNAPSHOT_DEFAULT_TABLE_OPTIONS: SnapshotTableOptions = { + sortField: 'startTimeInMillis', + sortDirection: 'desc', + pageIndex: 0, + pageSize: 20, +}; +const sortFieldToESParams = { + snapshot: 'name', + indices: 'index_count', + startTimeInMillis: 'start_time', + durationInMillis: 'duration', +}; +export const convertSortFieldToES = (sortField: SortField): string => { + return sortFieldToESParams[sortField]; +}; 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..fe9fe5c2db17a 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 @@ -19,7 +19,8 @@ import { EuiIcon, } from '@elastic/eui'; -import { APP_SLM_CLUSTER_PRIVILEGES, SNAPSHOT_LIST_MAX_SIZE } from '../../../../../common'; +import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../common'; +import { SNAPSHOT_DEFAULT_TABLE_OPTIONS, SnapshotTableOptions } from '../../../../../common/lib'; import { WithPrivileges, PageLoading, PageError, Error } from '../../../../shared_imports'; import { BASE_PATH, UIM_SNAPSHOT_LIST_LOAD } from '../../../constants'; import { useLoadSnapshots } from '../../../services/http'; @@ -28,7 +29,6 @@ import { linkToAddRepository, linkToPolicies, linkToAddPolicy, - linkToSnapshot, } from '../../../services/navigation'; import { useCore, useServices } from '../../../app_context'; import { useDecodedParams } from '../../../lib'; @@ -47,23 +47,20 @@ export const SnapshotList: React.FunctionComponent { const { repositoryName, snapshotId } = useDecodedParams(); + const [tableOptions, setTableOptions] = useState( + SNAPSHOT_DEFAULT_TABLE_OPTIONS + ); const { error, + isInitialRequest, isLoading, - data: { snapshots = [], repositories = [], policies = [], errors = {} }, + data: { snapshots = [], repositories = [], policies = [], errors = {}, total: totalItemCount }, resendRequest: reload, - } = useLoadSnapshots(); + } = useLoadSnapshots(tableOptions); - const { uiMetricService, i18n } = useServices(); + const { uiMetricService } = useServices(); const { docLinks } = useCore(); - const openSnapshotDetailsUrl = ( - repositoryNameToOpen: string, - snapshotIdToOpen: string - ): string => { - return linkToSnapshot(repositoryNameToOpen, snapshotIdToOpen); - }; - const closeSnapshotDetails = () => { history.push(`${BASE_PATH}/snapshots`); }; @@ -110,7 +107,9 @@ export const SnapshotList: React.FunctionComponent @@ -214,7 +213,7 @@ export const SnapshotList: React.FunctionComponent ); - } else if (snapshots.length === 0) { + } else if (totalItemCount === 0) { content = ( ) : null; - const maxSnapshotsWarning = snapshots.length === SNAPSHOT_LIST_MAX_SIZE && ( - <> - - - - - ), - }} - /> - - - - ); - content = (
{repositoryErrorsWarning} - {maxSnapshotsWarning} -
); 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/snapshot_table/snapshot_table.tsx index 47f8d9b833e40..156a7cbe0b863 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/snapshot_table/snapshot_table.tsx @@ -9,31 +9,42 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, - EuiInMemoryTable, EuiLink, Query, EuiLoadingSpinner, EuiToolTip, EuiButtonIcon, + Criteria, + EuiBasicTable, } from '@elastic/eui'; +import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; import { SnapshotDetails } from '../../../../../../common/types'; +import { SnapshotTableOptions, SortField, SortDirection } from '../../../../../../common/lib'; import { UseRequestResponse } 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, +} from '../../../../services/navigation'; import { DataPlaceholder, FormattedDateTime, SnapshotDeleteProvider } from '../../../../components'; -import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; - +const openSnapshotDetailsUrl = (repositoryNameToOpen: string, snapshotIdToOpen: string): string => { + return linkToSnapshot(repositoryNameToOpen, snapshotIdToOpen); +}; 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; + tableOptions: SnapshotTableOptions; + setTableOptions: (options: SnapshotTableOptions) => void; + totalItemCount: number; + isLoading: boolean; } const getLastSuccessfulManagedSnapshot = ( @@ -55,10 +66,13 @@ export const SnapshotTable: React.FunctionComponent = ({ snapshots, repositories, reload, - openSnapshotDetailsUrl, onSnapshotDeleted, repositoryFilter, policyFilter, + tableOptions, + setTableOptions, + totalItemCount, + isLoading, }) => { const { i18n, uiMetricService, history } = useServices(); const [selectedItems, setSelectedItems] = useState([]); @@ -92,7 +106,7 @@ export const SnapshotTable: React.FunctionComponent = ({ defaultMessage: 'Repository', }), truncateText: true, - sortable: true, + sortable: false, render: (repositoryName: string) => ( = ({ defaultMessage: 'Shards', }), truncateText: true, - sortable: true, + sortable: false, width: '100px', render: (totalShards: number) => totalShards, }, @@ -128,7 +142,7 @@ export const SnapshotTable: React.FunctionComponent = ({ defaultMessage: 'Failed shards', }), truncateText: true, - sortable: true, + sortable: false, width: '100px', render: (failedShards: number) => failedShards, }, @@ -263,16 +277,17 @@ export const SnapshotTable: React.FunctionComponent = ({ }, ]; - // By default, we'll display the most recent snapshots at the top of the table. const sorting = { sort: { - field: 'startTimeInMillis', - direction: 'desc' as const, + field: tableOptions.sortField, + direction: tableOptions.sortDirection, }, }; const pagination = { - initialPageSize: 20, + pageIndex: tableOptions.pageIndex, + pageSize: tableOptions.pageSize, + totalItemCount, pageSizeOptions: [10, 20, 50], }; @@ -387,12 +402,22 @@ export const SnapshotTable: React.FunctionComponent = ({ }; return ( - ) => { + const { page: { index, size } = {}, sort: { field, direction } = {} } = criteria; + setTableOptions({ + sortField: field as SortField, + sortDirection: direction as SortDirection, + pageIndex: index ?? tableOptions.pageIndex, + pageSize: size ?? tableOptions.pageSize, + }); + }} + loading={isLoading} isSelectable={true} selection={selection} pagination={pagination} 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..f180c03f37397 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,10 +5,12 @@ * 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 { UiMetricService } from '../ui_metric'; import { sendRequest, useRequest } from './use_request'; +import { SnapshotTableOptions } from '../../../../common/lib'; // Temporary hack to provide the uiMetricService instance to this file. // TODO: Refactor and export an ApiService instance through the app dependencies context @@ -18,11 +20,12 @@ export const setUiMetricServiceSnapshot = (_uiMetricService: UiMetricService) => }; // End hack -export const useLoadSnapshots = () => +export const useLoadSnapshots = (query: SnapshotTableOptions) => 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/server/routes/api/snapshots.ts b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts index fb68ca5c13dbe..e9de737362b9b 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -7,12 +7,23 @@ 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 { deserializeSnapshotDetails, convertSortFieldToES } from '../../../common/lib'; import type { RouteDependencies } from '../../types'; import { getManagedRepositoryName } from '../../lib'; import { addBasePath } from '../helpers'; +const querySchema = schema.object({ + sortField: schema.oneOf([ + schema.literal('snapshot'), + schema.literal('indices'), + schema.literal('durationInMillis'), + schema.literal('startTimeInMillis'), + ]), + sortDirection: schema.oneOf([schema.literal('desc'), schema.literal('asc')]), + pageIndex: schema.number(), + pageSize: schema.number(), +}); + export function registerSnapshotsRoutes({ router, license, @@ -20,9 +31,13 @@ export function registerSnapshotsRoutes({ }: RouteDependencies) { // GET all snapshots router.get( - { path: addBasePath('snapshots'), validate: false }, + { path: addBasePath('snapshots'), validate: { query: querySchema } }, license.guardApiRoute(async (ctx, req, res) => { const { client: clusterClient } = ctx.core.elasticsearch; + const sortField = convertSortFieldToES((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 managedRepository = await getManagedRepositoryName(clusterClient.asCurrentUser); @@ -63,11 +78,10 @@ export function registerSnapshotsRoutes({ snapshot: '_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, + order: sortDirection, + sort: sortField, + size: pageSize, + offset: pageIndex * pageSize, }); // Decorate each snapshot with the repository with which it's associated. @@ -82,6 +96,8 @@ export function registerSnapshotsRoutes({ repositories, // @ts-expect-error @elastic/elasticsearch "failures" is a new field in the response errors: fetchedSnapshots?.failures, + // @ts-expect-error @elastic/elasticsearch "total" is a new field in the response + total: fetchedSnapshots?.total, }, }); } catch (e) { From 25c14d07cc78c42df288dee95aa4aa7ae8ceed5c Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Mon, 30 Aug 2021 16:35:35 +0200 Subject: [PATCH 02/21] [Snapshot & Restore] Added server side sorting by shards and failed shards counts --- .../common/lib/snapshot_table_options.ts | 10 +++++++++- .../snapshot_list/snapshot_table/snapshot_table.tsx | 4 ++-- .../snapshot_restore/server/routes/api/snapshots.ts | 2 ++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts b/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts index 678442f6c7ce0..8c57225ef3fb0 100644 --- a/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts +++ b/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts @@ -7,7 +7,13 @@ import { Direction } from '@elastic/eui'; -export type SortField = 'snapshot' | 'indices' | 'startTimeInMillis' | 'durationInMillis'; +export type SortField = + | 'snapshot' + | 'indices' + | 'startTimeInMillis' + | 'durationInMillis' + | 'shards.total' + | 'shards.failed'; export type SortDirection = Direction; export interface SnapshotTableOptions { sortField: SortField; @@ -28,6 +34,8 @@ const sortFieldToESParams = { indices: 'index_count', startTimeInMillis: 'start_time', durationInMillis: 'duration', + 'shards.total': 'shard_count', + 'shards.failed': 'failed_shard_count', }; export const convertSortFieldToES = (sortField: SortField): string => { return sortFieldToESParams[sortField]; 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/snapshot_table/snapshot_table.tsx index 156a7cbe0b863..4295ebc777921 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/snapshot_table/snapshot_table.tsx @@ -132,7 +132,7 @@ export const SnapshotTable: React.FunctionComponent = ({ defaultMessage: 'Shards', }), truncateText: true, - sortable: false, + sortable: true, width: '100px', render: (totalShards: number) => totalShards, }, @@ -142,7 +142,7 @@ export const SnapshotTable: React.FunctionComponent = ({ defaultMessage: 'Failed shards', }), truncateText: true, - sortable: false, + sortable: true, width: '100px', render: (failedShards: number) => failedShards, }, 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 e9de737362b9b..2d591080cfa8f 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -18,6 +18,8 @@ const querySchema = schema.object({ 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(), From e899c1e7dc63e9a8d3fb2c19e5a63c0a0b496487 Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Mon, 30 Aug 2021 16:37:52 +0200 Subject: [PATCH 03/21] [Snapshot & Restore] Fixed i18n errors --- x-pack/plugins/translations/translations/ja-JP.json | 3 --- x-pack/plugins/translations/translations/zh-CN.json | 3 --- 2 files changed, 6 deletions(-) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6115e07189ed1..134ba5beba778 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23156,9 +23156,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 c7aff32f16dbd..991f8244c5fda 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23698,9 +23698,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": "不兼容版本", From fcf9420e01db2d395ac7f177d5e93baa9ebd284c Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Tue, 14 Sep 2021 11:06:10 +0200 Subject: [PATCH 04/21] [Snapshot & Restore] Added server side sorting by repository --- .../snapshot_restore/common/lib/snapshot_table_options.ts | 2 ++ .../home/snapshot_list/snapshot_table/snapshot_table.tsx | 2 +- x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts b/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts index 8c57225ef3fb0..e2e51c78e0b22 100644 --- a/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts +++ b/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts @@ -9,6 +9,7 @@ import { Direction } from '@elastic/eui'; export type SortField = | 'snapshot' + | 'repository' | 'indices' | 'startTimeInMillis' | 'durationInMillis' @@ -31,6 +32,7 @@ export const SNAPSHOT_DEFAULT_TABLE_OPTIONS: SnapshotTableOptions = { }; const sortFieldToESParams = { snapshot: 'name', + repository: 'repository', indices: 'index_count', startTimeInMillis: 'start_time', durationInMillis: 'duration', 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/snapshot_table/snapshot_table.tsx index 4295ebc777921..a64a933606972 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/snapshot_table/snapshot_table.tsx @@ -106,7 +106,7 @@ export const SnapshotTable: React.FunctionComponent = ({ defaultMessage: 'Repository', }), truncateText: true, - sortable: false, + sortable: true, render: (repositoryName: string) => ( Date: Tue, 21 Sep 2021 10:08:29 +0200 Subject: [PATCH 05/21] [Snapshot & Restore] Implemented server side search request for snapshot, repository and policy name --- .../helpers/http_requests.ts | 4 +- .../__jest__/client_integration/home.test.ts | 14 +- .../snapshot_restore/common/lib/index.ts | 7 - .../common/lib/snapshot_table_options.ts | 44 --- .../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 | 153 ++++++++++ .../snapshot_table.tsx | 228 +++++---------- .../home/snapshot_list/snapshot_list.tsx | 274 +++--------------- .../services/http/snapshot_requests.ts | 4 +- .../snapshot_restore/public/shared_imports.ts | 2 + .../server/routes/api/snapshots.test.ts | 4 + .../server/routes/api/snapshots.ts | 49 +++- 17 files changed, 666 insertions(+), 454 deletions(-) delete mode 100644 x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts 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 (69%) 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/lib/index.ts b/x-pack/plugins/snapshot_restore/common/lib/index.ts index 6128b59f3a247..fc8015c5b807b 100644 --- a/x-pack/plugins/snapshot_restore/common/lib/index.ts +++ b/x-pack/plugins/snapshot_restore/common/lib/index.ts @@ -17,10 +17,3 @@ export { export { deserializePolicy, serializePolicy } from './policy_serialization'; export { csvToArray } from './utils'; export { isDataStreamBackingIndex } from './is_data_stream_backing_index'; -export { - SnapshotTableOptions, - SortField, - SortDirection, - SNAPSHOT_DEFAULT_TABLE_OPTIONS, - convertSortFieldToES, -} from './snapshot_table_options'; diff --git a/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts b/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts deleted file mode 100644 index e2e51c78e0b22..0000000000000 --- a/x-pack/plugins/snapshot_restore/common/lib/snapshot_table_options.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 } from '@elastic/eui'; - -export type SortField = - | 'snapshot' - | 'repository' - | 'indices' - | 'startTimeInMillis' - | 'durationInMillis' - | 'shards.total' - | 'shards.failed'; -export type SortDirection = Direction; -export interface SnapshotTableOptions { - sortField: SortField; - sortDirection: SortDirection; - pageIndex: number; - pageSize: number; -} - -// By default, we'll display the most recent snapshots at the top of the table. -export const SNAPSHOT_DEFAULT_TABLE_OPTIONS: SnapshotTableOptions = { - sortField: 'startTimeInMillis', - sortDirection: 'desc', - pageIndex: 0, - pageSize: 20, -}; -const sortFieldToESParams = { - snapshot: 'name', - repository: 'repository', - indices: 'index_count', - startTimeInMillis: 'start_time', - durationInMillis: 'duration', - 'shards.total': 'shard_count', - 'shards.failed': 'failed_shard_count', -}; -export const convertSortFieldToES = (sortField: SortField): string => { - return sortFieldToESParams[sortField]; -}; 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..82aeaaaaa0844 --- /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; + +export interface SnapshotTableOptions { + sortField: SortField; + sortDirection: SortDirection; + pageIndex: number; + pageSize: number; +} +export interface SnapshotSearchOptions { + searchField?: string; + searchValue?: string; + searchMatch?: string; + searchOperator?: string; +} +export type SnapshotListParams = SnapshotTableOptions & SnapshotSearchOptions; + +// 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..9354e184f79b3 --- /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..12b5503fa5d70 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx @@ -0,0 +1,153 @@ +/* + * 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 { 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 moreThanOneSearchClauseError = i18n.translate( + 'xpack.snapshotRestore.snapshotList.table.moreThanOneSearchClauseError', + { + defaultMessage: 'More than 1 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 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 (query.ast.clauses.length > 1) { + setError({ name: moreThanOneSearchClauseError, message: moreThanOneSearchClauseError }); + } else { + setListParams(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 69% 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 a64a933606972..cbad247b3c87a 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,45 +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, EuiLink, - Query, EuiLoadingSpinner, EuiToolTip, EuiButtonIcon, Criteria, EuiBasicTable, } from '@elastic/eui'; - -import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; import { SnapshotDetails } from '../../../../../../common/types'; -import { SnapshotTableOptions, SortField, SortDirection } from '../../../../../../common/lib'; -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, - linkToSnapshot, + linkToSnapshot as openSnapshotDetailsUrl, } from '../../../../services/navigation'; +import { SnapshotListParams, SortDirection, SortField } from '../../../../lib'; import { DataPlaceholder, FormattedDateTime, SnapshotDeleteProvider } from '../../../../components'; - -const openSnapshotDetailsUrl = (repositoryNameToOpen: string, snapshotIdToOpen: string): string => { - return linkToSnapshot(repositoryNameToOpen, snapshotIdToOpen); -}; -interface Props { - snapshots: SnapshotDetails[]; - repositories: string[]; - reload: UseRequestResponse['resendRequest']; - repositoryFilter?: string; - policyFilter?: string; - onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void; - tableOptions: SnapshotTableOptions; - setTableOptions: (options: SnapshotTableOptions) => void; - totalItemCount: number; - isLoading: boolean; -} +import { SnapshotSearchBar } from './snapshot_search_bar'; const getLastSuccessfulManagedSnapshot = ( snapshots: SnapshotDetails[] @@ -62,18 +45,28 @@ const getLastSuccessfulManagedSnapshot = ( return successfulSnapshots[0]; }; -export const SnapshotTable: React.FunctionComponent = ({ - snapshots, - repositories, - reload, - onSnapshotDeleted, - repositoryFilter, - policyFilter, - tableOptions, - setTableOptions, - totalItemCount, - isLoading, -}) => { +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([]); @@ -277,31 +270,20 @@ export const SnapshotTable: React.FunctionComponent = ({ }, ]; - const sorting = { + const sorting: EuiTableSortingType = { sort: { - field: tableOptions.sortField, - direction: tableOptions.sortDirection, + field: listParams.sortField as keyof SnapshotDetails, + direction: listParams.sortDirection, }, }; const pagination = { - pageIndex: tableOptions.pageIndex, - pageSize: tableOptions.pageSize, + 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) => @@ -321,113 +303,43 @@ 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 ( - ) => { - const { page: { index, size } = {}, sort: { field, direction } = {} } = criteria; - setTableOptions({ - sortField: field as SortField, - sortDirection: direction as SortDirection, - pageIndex: index ?? tableOptions.pageIndex, - pageSize: size ?? tableOptions.pageSize, - }); - }} - loading={isLoading} - isSelectable={true} - selection={selection} - pagination={pagination} - rowProps={() => ({ - 'data-test-subj': 'row', - })} - cellProps={() => ({ - 'data-test-subj': 'cell', - })} - data-test-subj="snapshotTable" - /> + <> + + ) => { + const { page: { index, size } = {}, sort: { field, direction } = {} } = criteria; + + setListParams({ + 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 fe9fe5c2db17a..c57e017f50a50 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 } from '../../../../../common'; -import { SNAPSHOT_DEFAULT_TABLE_OPTIONS, SnapshotTableOptions } from '../../../../../common/lib'; -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, -} 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,19 +36,22 @@ export const SnapshotList: React.FunctionComponent { const { repositoryName, snapshotId } = useDecodedParams(); - const [tableOptions, setTableOptions] = useState( - SNAPSHOT_DEFAULT_TABLE_OPTIONS - ); + const [listParams, setListParams] = useState(DEFAULT_SNAPSHOT_LIST_PARAMS); const { error, isInitialRequest, isLoading, - data: { snapshots = [], repositories = [], policies = [], errors = {}, total: totalItemCount }, + data: { + snapshots = [], + repositories = [], + policies = [], + errors = {}, + total: totalSnapshotsCount, + }, resendRequest: reload, - } = useLoadSnapshots(tableOptions); + } = useLoadSnapshots(listParams); const { uiMetricService } = useServices(); - const { docLinks } = useCore(); const closeSnapshotDetails = () => { history.push(`${BASE_PATH}/snapshots`); @@ -83,22 +75,34 @@ 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) { + // setQuery(Query.parse(`policyName="${String(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) { + // setQuery(Query.parse(`repository="${String(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(() => { @@ -108,7 +112,6 @@ export const SnapshotList: React.FunctionComponent @@ -133,190 +136,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 (totalItemCount === 0) { - content = ( - - - - - } - body={ - `cluster.${name}`)} - > - {({ hasPrivileges }) => - hasPrivileges ? ( - -

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

-

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

-
- ) : ( - -

- -

-

- - {' '} - - -

-
- ) - } -
- } - data-test-subj="emptyPrompt" - /> -
- ); + content = ; + } else if (totalSnapshotsCount === 0 && !listParams.searchField) { + content = ; } else { const repositoryErrorsWarning = Object.keys(errors).length ? ( <> @@ -359,11 +183,9 @@ export const SnapshotList: React.FunctionComponent 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 f180c03f37397..39c965a9b60fa 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 @@ -8,9 +8,9 @@ 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'; -import { SnapshotTableOptions } from '../../../../common/lib'; // Temporary hack to provide the uiMetricService instance to this file. // TODO: Refactor and export an ApiService instance through the app dependencies context @@ -20,7 +20,7 @@ export const setUiMetricServiceSnapshot = (_uiMetricService: UiMetricService) => }; // End hack -export const useLoadSnapshots = (query: SnapshotTableOptions) => +export const useLoadSnapshots = (query: SnapshotListParams) => useRequest({ path: `${API_BASE_PATH}snapshots`, method: 'get', 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/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 7a945baeb7425..d1586f011b3f2 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -7,7 +7,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import type { SnapshotDetailsEs } from '../../../common/types'; -import { deserializeSnapshotDetails, convertSortFieldToES } from '../../../common/lib'; +import { deserializeSnapshotDetails } from '../../../common/lib'; import type { RouteDependencies } from '../../types'; import { getManagedRepositoryName } from '../../lib'; import { addBasePath } from '../helpers'; @@ -25,8 +25,33 @@ const querySchema = schema.object({ 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')])), }); +const sortFieldToESParams = { + snapshot: 'name', + repository: 'repository', + indices: 'index_count', + startTimeInMillis: 'start_time', + durationInMillis: 'duration', + 'shards.total': 'shard_count', + 'shards.failed': 'failed_shard_count', +}; + +const convertSearchToWildcard = (value: string, match?: string, operator?: string) => { + const wildcard = operator === 'exact' ? '' : '*'; + return match === 'must_not' ? `*,-${value}${wildcard}` : `${value}${wildcard}`; +}; + export function registerSnapshotsRoutes({ router, license, @@ -37,10 +62,14 @@ export function registerSnapshotsRoutes({ { path: addBasePath('snapshots'), validate: { query: querySchema } }, license.guardApiRoute(async (ctx, req, res) => { const { client: clusterClient } = ctx.core.elasticsearch; - const sortField = convertSortFieldToES((req.query as TypeOf).sortField); + 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); @@ -77,10 +106,20 @@ export function registerSnapshotsRoutes({ 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' + ? convertSearchToWildcard(searchValue!, searchMatch, 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 + snapshot: + searchField === 'snapshot' + ? convertSearchToWildcard(searchValue!, searchMatch, searchOperator) + : '_all', + // @ts-expect-error @elastic/elasticsearch new API params + slm_policy_filter: + searchField === 'policyName' + ? convertSearchToWildcard(searchValue!, searchMatch, searchOperator) + : '*,_none', order: sortDirection, sort: sortField, size: pageSize, From 6fab67833fcdb2087f313ddedfd9b7b4eee845f3 Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Tue, 21 Sep 2021 13:35:39 +0200 Subject: [PATCH 06/21] [Snapshot & Restore] Fixed eslint errors --- .../public/application/services/http/snapshot_requests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 39c965a9b60fa..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 @@ -25,7 +25,7 @@ export const useLoadSnapshots = (query: SnapshotListParams) => path: `${API_BASE_PATH}snapshots`, method: 'get', initialData: [], - query: (query as unknown) as HttpFetchQuery, + query: query as unknown as HttpFetchQuery, }); export const useLoadSnapshot = (repositoryName: string, snapshotId: string) => From 85d6b5f8cbaabd3e68a30e959f28cadef51ba97a Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Tue, 21 Sep 2021 14:22:36 +0200 Subject: [PATCH 07/21] [Snapshot & Restore] Removed uncommented code --- .../application/sections/home/snapshot_list/snapshot_list.tsx | 2 -- 1 file changed, 2 deletions(-) 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 c57e017f50a50..a2f5612debada 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 @@ -81,7 +81,6 @@ export const SnapshotList: React.FunctionComponent ({ ...prev, searchField: 'policyName', @@ -91,7 +90,6 @@ export const SnapshotList: React.FunctionComponent ({ ...prev, searchField: 'repository', From f84dfd576d3d35faeaccbbabf1ed19d841d2d0c7 Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Tue, 21 Sep 2021 14:37:02 +0200 Subject: [PATCH 08/21] [Snapshot & Restore] Fixed pagination/search bug --- .../home/snapshot_list/components/snapshot_search_bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 12b5503fa5d70..ad64e521450e2 100644 --- 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 @@ -123,7 +123,7 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ } else if (changedQuery) { setError(null); setQuery(changedQuery); - if (query.ast.clauses.length > 1) { + if (changedQuery.ast.clauses.length > 1) { setError({ name: moreThanOneSearchClauseError, message: moreThanOneSearchClauseError }); } else { setListParams(getListParams(listParams, changedQuery)); From 02ea7c19cf2728fceefd5b58688c027f6b2476bd Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Tue, 21 Sep 2021 14:37:26 +0200 Subject: [PATCH 09/21] [Snapshot & Restore] Fixed pagination/search bug --- .../sections/home/snapshot_list/components/snapshot_table.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx index cbad247b3c87a..ccd6b7fc745b1 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx @@ -322,6 +322,7 @@ export const SnapshotTable: React.FunctionComponent = (props: Props) => { 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, From 16891ceda037c59fdd8dd47ad4dc4c104d58fbfd Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Tue, 21 Sep 2021 15:09:46 +0200 Subject: [PATCH 10/21] [Snapshot & Restore] Fixed text truncate bug --- .../home/snapshot_list/components/snapshot_table.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx index ccd6b7fc745b1..5db702fcbd963 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx @@ -78,7 +78,7 @@ export const SnapshotTable: React.FunctionComponent = (props: Props) => { name: i18n.translate('xpack.snapshotRestore.snapshotList.table.snapshotColumnTitle', { defaultMessage: 'Snapshot', }), - truncateText: true, + truncateText: false, sortable: true, render: (snapshotId: string, snapshot: SnapshotDetails) => ( = (props: Props) => { name: i18n.translate('xpack.snapshotRestore.snapshotList.table.repositoryColumnTitle', { defaultMessage: 'Repository', }), - truncateText: true, + truncateText: false, sortable: true, render: (repositoryName: string) => ( = (props: Props) => { name: i18n.translate('xpack.snapshotRestore.snapshotList.table.startTimeColumnTitle', { defaultMessage: 'Date created', }), - truncateText: true, + truncateText: false, sortable: true, render: (startTimeInMillis: number) => ( From 1334f409ddbe78b9ab9182797f7397318d858a83 Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Tue, 21 Sep 2021 16:06:44 +0200 Subject: [PATCH 11/21] [Snapshot & Restore] Fixed non existent repository search error --- .../components/snapshot_search_bar.tsx | 2 +- .../server/routes/api/snapshots.ts | 33 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) 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 index ad64e521450e2..6953ee1c30feb 100644 --- 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 @@ -28,7 +28,7 @@ const searchSchema: SchemaType = { strict: true, fields: { snapshot: { - type: 'string,', + type: 'string', }, repository: { type: 'string', 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 bed1415fb2718..da13a82253fef 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -47,11 +47,25 @@ const sortFieldToESParams = { 'shards.failed': 'failed_shard_count', }; -const convertSearchToWildcard = (value: string, match?: string, operator?: string) => { +const convertSearchToWildcard = (value: string, match?: string, operator?: string): string => { const wildcard = operator === 'exact' ? '' : '*'; return match === 'must_not' ? `*,-${value}${wildcard}` : `${value}${wildcard}`; }; +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, license, @@ -102,6 +116,23 @@ 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({ From 9ca9a08b1c31c45ac7ffd762b0a5c8c6204fc696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Wed, 29 Sep 2021 16:24:59 +0200 Subject: [PATCH 12/21] Update x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx Co-authored-by: CJ Cenizal --- .../home/snapshot_list/components/snapshot_search_bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6953ee1c30feb..de219e2862245 100644 --- 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 @@ -20,7 +20,7 @@ import { getQueryFromListParams, SnapshotListParams, getListParams } from '../.. const moreThanOneSearchClauseError = i18n.translate( 'xpack.snapshotRestore.snapshotList.table.moreThanOneSearchClauseError', { - defaultMessage: 'More than 1 clause in the search bar', + defaultMessage: 'You can only use one clause in the search bar', } ); // for now limit the search bar to snapshot, repository and policyName queries From 5db21add96700c458e91a3bc7f9febfc4e55a7da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Wed, 29 Sep 2021 20:25:11 +0200 Subject: [PATCH 13/21] 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> --- .../home/snapshot_list/components/snapshot_empty_prompt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 9354e184f79b3..2cfc1d5ebefc5 100644 --- 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 @@ -45,7 +45,7 @@ export const SnapshotEmptyPrompt: React.FunctionComponent<{ policiesCount: numbe Date: Mon, 4 Oct 2021 20:04:21 +0200 Subject: [PATCH 14/21] [Snapshot & Restore] Fixed missing i18n and no snapshots callout --- .../components/snapshot_search_bar.tsx | 20 +++++++++++++++---- .../home/snapshot_list/snapshot_list.tsx | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) 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 index de219e2862245..40239881056cd 100644 --- 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 @@ -17,8 +17,8 @@ import { SnapshotDeleteProvider } from '../../../../components'; import { SnapshotDetails } from '../../../../../../common/types'; import { getQueryFromListParams, SnapshotListParams, getListParams } from '../../../../lib'; -const moreThanOneSearchClauseError = i18n.translate( - 'xpack.snapshotRestore.snapshotList.table.moreThanOneSearchClauseError', +const onlyOneClauseMessage = i18n.translate( + 'xpack.snapshotRestore.snapshotList.searchBar.onlyOneClauseMessage', { defaultMessage: 'You can only use one clause in the search bar', } @@ -124,7 +124,7 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ setError(null); setQuery(changedQuery); if (changedQuery.ast.clauses.length > 1) { - setError({ name: moreThanOneSearchClauseError, message: moreThanOneSearchClauseError }); + setError({ name: onlyOneClauseMessage, message: onlyOneClauseMessage }); } else { setListParams(getListParams(listParams, changedQuery)); } @@ -144,7 +144,19 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ {error ? ( <> - + + } + /> ) : null} 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 a2f5612debada..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 @@ -137,7 +137,7 @@ export const SnapshotList: React.FunctionComponent; } else if (repositories.length === 0) { content = ; - } else if (totalSnapshotsCount === 0 && !listParams.searchField) { + } else if (totalSnapshotsCount === 0 && !listParams.searchField && !isLoading) { content = ; } else { const repositoryErrorsWarning = Object.keys(errors).length ? ( From c9467f72251e3a21d78556ebc6253c5a28bb6996 Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Mon, 4 Oct 2021 20:07:13 +0200 Subject: [PATCH 15/21] [Snapshot & Restore] Moved "getSnapshotSearchWildcard" to a separate file and added unit tests --- .../lib/get_snapshot_search_wildcard.test.ts | 60 +++++++++++++++ .../lib/get_snapshot_search_wildcard.ts | 30 ++++++++ .../server/routes/api/snapshots.ts | 74 ++++++++----------- .../server/routes/api/validate_schemas.ts | 25 +++++++ .../snapshot_restore/server/routes/helpers.ts | 2 +- 5 files changed, 147 insertions(+), 44 deletions(-) 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 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..d3f9ec9e14daf --- /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..ef5df6fdd89ee --- /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 => { + // add * wildcard if the operator is NOT for exact match + 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.ts b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts index da13a82253fef..37e6bdb78b84c 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -11,31 +11,8 @@ import { deserializeSnapshotDetails } from '../../../common/lib'; import type { RouteDependencies } from '../../types'; import { getManagedRepositoryName } from '../../lib'; import { addBasePath } from '../helpers'; - -const querySchema = 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')])), -}); +import { snapshotListSchema } from './validate_schemas'; +import { getSnapshotSearchWildcard } from '../../lib/get_snapshot_search_wildcard'; const sortFieldToESParams = { snapshot: 'name', @@ -47,11 +24,6 @@ const sortFieldToESParams = { 'shards.failed': 'failed_shard_count', }; -const convertSearchToWildcard = (value: string, match?: string, operator?: string): string => { - const wildcard = operator === 'exact' ? '' : '*'; - return match === 'must_not' ? `*,-${value}${wildcard}` : `${value}${wildcard}`; -}; - const isSearchingForNonExistentRepository = ( repositories: string[], value: string, @@ -73,17 +45,18 @@ export function registerSnapshotsRoutes({ }: RouteDependencies) { // GET all snapshots router.get( - { path: addBasePath('snapshots'), validate: { query: querySchema } }, + { 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 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); @@ -138,17 +111,32 @@ export function registerSnapshotsRoutes({ const { body: fetchedSnapshots } = await clusterClient.asCurrentUser.snapshot.get({ repository: searchField === 'repository' - ? convertSearchToWildcard(searchValue!, searchMatch, searchOperator) + ? getSnapshotSearchWildcard({ + field: searchField, + value: searchValue!, + match: searchMatch, + operator: searchOperator, + }) : '_all', ignore_unavailable: true, // Allow request to succeed even if some snapshots are unavailable. snapshot: searchField === 'snapshot' - ? convertSearchToWildcard(searchValue!, searchMatch, searchOperator) + ? getSnapshotSearchWildcard({ + field: searchField, + value: searchValue!, + match: searchMatch, + operator: searchOperator, + }) : '_all', // @ts-expect-error @elastic/elasticsearch new API params slm_policy_filter: searchField === 'policyName' - ? convertSearchToWildcard(searchValue!, searchMatch, searchOperator) + ? getSnapshotSearchWildcard({ + field: searchField, + value: searchValue!, + match: searchMatch, + operator: searchOperator, + }) : '*,_none', order: sortDirection, sort: sortField, @@ -259,7 +247,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; From 5e84030655efa2e9b639678b59a02df70f723576 Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Mon, 4 Oct 2021 20:09:04 +0200 Subject: [PATCH 16/21] [Snapshot & Restore] Added api integration tests for "get snapshots" endpoint (pagination, sorting, search) --- .../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 | 677 ++++++++++++++++++ x-pack/test/api_integration/config.ts | 1 + 6 files changed, 718 insertions(+), 15 deletions(-) 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/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..9b820ee521148 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts @@ -0,0 +1,677 @@ +/* + * 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_repo_2'; +const REPO_PATH_1 = '/tmp/repo_1'; +const REPO_PATH_2 = '/tmp/repo_2'; +const POLICY_NAME_1 = 'test_policy_1'; +const POLICY_NAME_2 = 'test_policy_2'; +const BATCH_SIZE_1 = 3; +const BATCH_SIZE_2 = 5; +const SNAPSHOT_COUNT = 10; +// 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: 'test_policy', + name: 'test_snapshot', + schedule: '0 30 1 * * ?', + repository: 'test_repo', + isManagedPolicy: false, + config: { + indices: ['my_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 + + let snapshotName1: string; + let snapshotName2: string; + + before(async () => { + 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: 'backup_snapshot', + }), + true + ); + await createPolicy( + getPolicyBody({ + policyName: POLICY_NAME_2, + repository: REPO_NAME_2, + name: 'a_snapshot', + }), + 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(`another_snapshot_${i}`, REPO_NAME_1); + } + for (let i = 0; i < BATCH_SIZE_2; i++) { + await createSnapshot(`xyz_snapshot_${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(); + const snapshotName = snapshots[0].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(); + const snapshotName = snapshots[0].snapshot; + expect(snapshotName).to.eql('xyz_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(); + const snapshotName = snapshots[0].repository; + expect(snapshotName).to.eql(REPO_NAME_1); + }); + + it('sorts by repository name (desc)', async () => { + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + sortField: 'repository', + sortDirection: 'desc', + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + const snapshotName = snapshots[0].repository; + expect(snapshotName).to.eql(REPO_NAME_2); + }); + + 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_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 "a_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 starting with "xyz" + const searchField = 'snapshot'; + const searchValue = 'xyz'; + const searchMatch = 'must'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(BATCH_SIZE_2); + snapshots.forEach((snapshot: SnapshotDetails) => { + expect(snapshot.snapshot).to.contain('xyz'); + }); + }); + + it('excluding search with exact match', async () => { + // list snapshots with the name not "xyz_snapshot_0f" + const searchField = 'snapshot'; + const searchValue = 'xyz_snapshot_0'; + 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); + snapshots.forEach((snapshot: SnapshotDetails) => { + expect(snapshot.snapshot).to.not.eql('xyz_snapshot_0'); + }); + }); + + it('excluding search with partial match', async () => { + // list snapshots with the name not starting with "xyz" + const searchField = 'snapshot'; + const searchValue = 'xyz'; + 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(SNAPSHOT_COUNT - BATCH_SIZE_2); + snapshots.forEach((snapshot: SnapshotDetails) => { + expect(snapshot.snapshot).to.not.contain('xyz'); + }); + }); + }); + + 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(); + + expect(snapshots.length).to.eql(1 + BATCH_SIZE_1); + snapshots.forEach((snapshot: SnapshotDetails) => { + expect(snapshot.repository).to.eql(REPO_NAME_1); + }); + }); + + it('partial match', async () => { + // list snapshots from repository with the name starting with "test" + const searchField = 'repository'; + const searchValue = 'test'; + const searchMatch = 'must'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(SNAPSHOT_COUNT); + }); + + 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(); + + expect(snapshots.length).to.eql(BATCH_SIZE_2 + 1); + snapshots.forEach((snapshot: SnapshotDetails) => { + expect(snapshot.repository).to.not.eql(REPO_NAME_1); + }); + }); + + it('excluding search with partial match', async () => { + // list snapshots from repository with the name not starting with "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 starting with "test" + const searchField = 'policyName'; + const searchValue = 'test'; + const searchMatch = 'must'; + const searchOperator = 'eq'; + const { + body: { snapshots }, + } = await supertest + .get( + getApiPath({ + searchField, + searchValue, + searchMatch, + searchOperator, + }) + ) + .set('kbn-xsrf', 'xxx') + .send(); + + expect(snapshots.length).to.eql(2); + snapshots.forEach((snapshot: SnapshotDetails) => { + expect(snapshot.policyName).to.contain('test'); + }); + }); + + it('excluding search with exact match', async () => { + // list snapshots created by the policy with the name not "test_policy_2" + const searchField = 'policyName'; + const searchValue = POLICY_NAME_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); + snapshots.forEach((snapshot: SnapshotDetails) => { + expect(snapshot.repository).to.not.eql(POLICY_NAME_2); + }); + }); + + it('excluding search with partial match', async () => { + // list snapshots created by the policy with the name not starting with "test" + const searchField = 'policyName'; + 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(SNAPSHOT_COUNT - 2); + snapshots.forEach((snapshot: SnapshotDetails) => { + // policy name might be null if the snapshot was created not by a policy + expect(snapshot.policyName ?? '').to.not.contain('test'); + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/config.ts b/x-pack/test/api_integration/config.ts index 9721a1827caf3..53166b2b0e8a6 100644 --- a/x-pack/test/api_integration/config.ts +++ b/x-pack/test/api_integration/config.ts @@ -42,6 +42,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', ], }, }; From e920e514f68c94e1e6d1cefad27ad6dec6b4fc2a Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Tue, 5 Oct 2021 15:03:02 +0200 Subject: [PATCH 17/21] [Snapshot & Restore] Renamed SnapshotSearchOptions/SnapshotTableOptions to -Params and added the link to the specs issue --- .../public/application/lib/snapshot_list_params.ts | 6 +++--- .../plugins/snapshot_restore/server/routes/api/snapshots.ts | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) 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 index 82aeaaaaa0844..b75a3e01fb617 100644 --- 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 @@ -18,19 +18,19 @@ export type SortField = export type SortDirection = Direction; -export interface SnapshotTableOptions { +interface SnapshotTableParams { sortField: SortField; sortDirection: SortDirection; pageIndex: number; pageSize: number; } -export interface SnapshotSearchOptions { +interface SnapshotSearchParams { searchField?: string; searchValue?: string; searchMatch?: string; searchOperator?: string; } -export type SnapshotListParams = SnapshotTableOptions & SnapshotSearchOptions; +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 = { 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 37e6bdb78b84c..4de0c3011fed5 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -129,6 +129,7 @@ export function registerSnapshotsRoutes({ }) : '_all', // @ts-expect-error @elastic/elasticsearch new API params + // https://github.com/elastic/elasticsearch-specification/issues/845 slm_policy_filter: searchField === 'policyName' ? getSnapshotSearchWildcard({ @@ -154,7 +155,7 @@ 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, From 347f0e4fc793b3cf03e8dca6bfa0f9ba741116ce Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Tue, 12 Oct 2021 15:02:45 +0200 Subject: [PATCH 18/21] [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. --- .../lib/get_snapshot_search_wildcard.test.ts | 4 +- .../lib/get_snapshot_search_wildcard.ts | 4 +- .../management/snapshot_restore/snapshots.ts | 184 +++++++++++------- 3 files changed, 122 insertions(+), 70 deletions(-) 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 index d3f9ec9e14daf..d3e5c604d22ad 100644 --- 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 @@ -22,7 +22,7 @@ describe('getSnapshotSearchWildcard', () => { 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*'); + expect(wildcard).toEqual('*testSearch*'); }); it('excluding search converts to "all, except" wildcard (exact match)', () => { @@ -44,7 +44,7 @@ describe('getSnapshotSearchWildcard', () => { match: 'must_not', }; const wildcard = getSnapshotSearchWildcard(searchParams); - expect(wildcard).toEqual('*,-testSearch*'); + expect(wildcard).toEqual('*,-*testSearch*'); }); it('excluding search for policy name converts to "all,_none, except" wildcard', () => { 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 index ef5df6fdd89ee..df8926d785712 100644 --- 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 @@ -18,8 +18,8 @@ export const getSnapshotSearchWildcard = ({ match, operator, }: SearchParams): string => { - // add * wildcard if the operator is NOT for exact match - value = operator === 'exact' ? value : `${value}*`; + // 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}" 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 index 9b820ee521148..1677013dd5e7e 100644 --- a/x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts +++ b/x-pack/test/api_integration/apis/management/snapshot_restore/snapshots.ts @@ -12,14 +12,21 @@ 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_repo_2'; +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_policy_2'; +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 SNAPSHOT_COUNT = 10; +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; @@ -59,13 +66,13 @@ const getApiPath = ({ }; const getPolicyBody = (policy: Partial): SlmPolicy => { return { - policyName: 'test_policy', - name: 'test_snapshot', + policyName: 'default_policy', + name: 'default_snapshot', schedule: '0 30 1 * * ?', - repository: 'test_repo', + repository: 'default_repo', isManagedPolicy: false, config: { - indices: ['my_index'], + indices: ['default_index'], ignoreUnavailable: true, }, ...policy, @@ -87,10 +94,21 @@ export default function ({ getService }: FtrProviderContext) { 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); @@ -98,15 +116,15 @@ export default function ({ getService }: FtrProviderContext) { getPolicyBody({ policyName: POLICY_NAME_1, repository: REPO_NAME_1, - name: 'backup_snapshot', + name: POLICY_SNAPSHOT_NAME_1, }), true ); await createPolicy( getPolicyBody({ policyName: POLICY_NAME_2, - repository: REPO_NAME_2, - name: 'a_snapshot', + repository: REPO_NAME_1, + name: POLICY_SNAPSHOT_NAME_2, }), true ); @@ -115,10 +133,10 @@ export default function ({ getService }: FtrProviderContext) { 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(`another_snapshot_${i}`, REPO_NAME_1); + await createSnapshot(`${BATCH_SNAPSHOT_NAME_1}_${i}`, REPO_NAME_1); } for (let i = 0; i < BATCH_SIZE_2; i++) { - await createSnapshot(`xyz_snapshot_${i}`, REPO_NAME_2); + await createSnapshot(`${BATCH_SNAPSHOT_NAME_2}_${i}`, REPO_NAME_2); } } catch (err) { // eslint-disable-next-line no-console @@ -200,7 +218,13 @@ export default function ({ getService }: FtrProviderContext) { ) .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); }); @@ -216,8 +240,12 @@ export default function ({ getService }: FtrProviderContext) { ) .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_snapshot_4'); + expect(snapshotName).to.eql('xyz_another_snapshot_4'); }); it('sorts by repository name (asc)', async () => { @@ -232,8 +260,9 @@ export default function ({ getService }: FtrProviderContext) { ) .set('kbn-xsrf', 'xxx') .send(); - const snapshotName = snapshots[0].repository; - expect(snapshotName).to.eql(REPO_NAME_1); + // 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 () => { @@ -248,8 +277,9 @@ export default function ({ getService }: FtrProviderContext) { ) .set('kbn-xsrf', 'xxx') .send(); - const snapshotName = snapshots[0].repository; - expect(snapshotName).to.eql(REPO_NAME_2); + // 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 () => { @@ -283,7 +313,7 @@ export default function ({ getService }: FtrProviderContext) { .send(); const snapshotName = snapshots[0].snapshot; // the last snapshot that was created during this test setup - expect(snapshotName).to.eql('xyz_snapshot_4'); + expect(snapshotName).to.eql('xyz_another_snapshot_4'); }); // these properties are only tested as being accepted by the API @@ -320,7 +350,7 @@ export default function ({ getService }: FtrProviderContext) { describe('search', () => { describe('snapshot name', () => { it('exact match', async () => { - // list snapshots with the name "a_snapshot_2" + // list snapshots with the name "another_snapshot_2" const searchField = 'snapshot'; const searchValue = 'another_snapshot_2'; const searchMatch = 'must'; @@ -344,9 +374,9 @@ export default function ({ getService }: FtrProviderContext) { }); it('partial match', async () => { - // list snapshots with the name starting with "xyz" + // list snapshots with the name containing with "another" const searchField = 'snapshot'; - const searchValue = 'xyz'; + const searchValue = 'another'; const searchMatch = 'must'; const searchOperator = 'eq'; const { @@ -363,16 +393,18 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send(); - expect(snapshots.length).to.eql(BATCH_SIZE_2); - snapshots.forEach((snapshot: SnapshotDetails) => { - expect(snapshot.snapshot).to.contain('xyz'); - }); + // 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 "xyz_snapshot_0f" + // list snapshots with the name not "another_snapshot_2" const searchField = 'snapshot'; - const searchValue = 'xyz_snapshot_0'; + const searchValue = 'another_snapshot_2'; const searchMatch = 'must_not'; const searchOperator = 'exact'; const { @@ -390,15 +422,16 @@ export default function ({ getService }: FtrProviderContext) { .send(); expect(snapshots.length).to.eql(SNAPSHOT_COUNT - 1); - snapshots.forEach((snapshot: SnapshotDetails) => { - expect(snapshot.snapshot).to.not.eql('xyz_snapshot_0'); - }); + 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 "xyz" + // list snapshots with the name not starting with "another" const searchField = 'snapshot'; - const searchValue = 'xyz'; + const searchValue = 'another'; const searchMatch = 'must_not'; const searchOperator = 'eq'; const { @@ -415,10 +448,12 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send(); - expect(snapshots.length).to.eql(SNAPSHOT_COUNT - BATCH_SIZE_2); - snapshots.forEach((snapshot: SnapshotDetails) => { - expect(snapshot.snapshot).to.not.contain('xyz'); - }); + // 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); }); }); @@ -466,16 +501,19 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send(); - expect(snapshots.length).to.eql(1 + BATCH_SIZE_1); - snapshots.forEach((snapshot: SnapshotDetails) => { - expect(snapshot.repository).to.eql(REPO_NAME_1); - }); + // 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 starting with "test" + // list snapshots from repository with the name containing "another" + // i.e. snapshots from repo 2 const searchField = 'repository'; - const searchValue = 'test'; + const searchValue = 'another'; const searchMatch = 'must'; const searchOperator = 'eq'; const { @@ -492,7 +530,12 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send(); - expect(snapshots.length).to.eql(SNAPSHOT_COUNT); + // 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 () => { @@ -515,14 +558,16 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send(); - expect(snapshots.length).to.eql(BATCH_SIZE_2 + 1); - snapshots.forEach((snapshot: SnapshotDetails) => { - expect(snapshot.repository).to.not.eql(REPO_NAME_1); - }); + // 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 starting with "test" + // list snapshots from repository with the name not containing "test" const searchField = 'repository'; const searchValue = 'test'; const searchMatch = 'must_not'; @@ -594,9 +639,9 @@ export default function ({ getService }: FtrProviderContext) { }); it('partial match', async () => { - // list snapshots created by the policy with the name starting with "test" + // list snapshots created by the policy with the name containing "another" const searchField = 'policyName'; - const searchValue = 'test'; + const searchValue = 'another'; const searchMatch = 'must'; const searchOperator = 'eq'; const { @@ -613,16 +658,18 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send(); - expect(snapshots.length).to.eql(2); - snapshots.forEach((snapshot: SnapshotDetails) => { - expect(snapshot.policyName).to.contain('test'); - }); + // 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_2" + // list snapshots created by the policy with the name not "test_policy_1" const searchField = 'policyName'; - const searchValue = POLICY_NAME_2; + const searchValue = POLICY_NAME_1; const searchMatch = 'must_not'; const searchOperator = 'exact'; const { @@ -639,16 +686,19 @@ export default function ({ getService }: FtrProviderContext) { .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); - snapshots.forEach((snapshot: SnapshotDetails) => { - expect(snapshot.repository).to.not.eql(POLICY_NAME_2); - }); + 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 starting with "test" + // list snapshots created by the policy with the name not containing "another" const searchField = 'policyName'; - const searchValue = 'test'; + const searchValue = 'another'; const searchMatch = 'must_not'; const searchOperator = 'eq'; const { @@ -665,11 +715,13 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send(); - expect(snapshots.length).to.eql(SNAPSHOT_COUNT - 2); - snapshots.forEach((snapshot: SnapshotDetails) => { - // policy name might be null if the snapshot was created not by a policy - expect(snapshot.policyName ?? '').to.not.contain('test'); - }); + // 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); }); }); }); From 61d21d42e5e6f9090a30e6dac8c60d84d73a49ac Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Thu, 14 Oct 2021 13:55:10 +0200 Subject: [PATCH 19/21] [Snapshot & Restore] Added incremental search back to snapshots list and a debounce of 500ms --- .../components/snapshot_search_bar.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 index 40239881056cd..99cf4722fbe37 100644 --- 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 @@ -6,6 +6,7 @@ */ import React, { useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -56,6 +57,15 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ onSnapshotDeleted, repositories, }) => { + const [debouncedValue, setDebouncedValue] = useState(listParams); + useDebounce( + () => { + setListParams(debouncedValue); + }, + 500, + [debouncedValue] + ); + const deleteButton = selectedItems.length ? ( {( @@ -126,7 +136,7 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ if (changedQuery.ast.clauses.length > 1) { setError({ name: onlyOneClauseMessage, message: onlyOneClauseMessage }); } else { - setListParams(getListParams(listParams, changedQuery)); + setDebouncedValue(getListParams(listParams, changedQuery)); } } }; @@ -139,7 +149,7 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ onChange={onSearchBarChange} toolsLeft={deleteButton} toolsRight={reloadButton} - box={{ schema: searchSchema }} + box={{ schema: searchSchema, incremental: true }} /> {error ? ( From 467f47c3a8db83e9f424a9251914f4863ca7c920 Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Fri, 15 Oct 2021 16:53:56 +0200 Subject: [PATCH 20/21] [Snapshot & Restore] Updated snapshot search debounce value and extracted it into a constant --- .../home/snapshot_list/components/snapshot_search_bar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 99cf4722fbe37..50e737b469fc2 100644 --- 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 @@ -18,6 +18,8 @@ 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', { @@ -62,7 +64,7 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ () => { setListParams(debouncedValue); }, - 500, + SEARCH_DEBOUNCE_VALUE_MS, [debouncedValue] ); From e139f95916e302b4b380fdd8ab8f0617cf90b7cb Mon Sep 17 00:00:00 2001 From: Yulia Cech Date: Mon, 18 Oct 2021 16:03:21 +0200 Subject: [PATCH 21/21] [Snapshot & Restore] Renamed debounceValue to cachedListParams and added a comment why debounce is used --- .../snapshot_list/components/snapshot_search_bar.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 50e737b469fc2..b3e2c24e396f0 100644 --- 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 @@ -59,13 +59,14 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ onSnapshotDeleted, repositories, }) => { - const [debouncedValue, setDebouncedValue] = useState(listParams); + const [cachedListParams, setCachedListParams] = useState(listParams); + // send the request after the user has stopped typing useDebounce( () => { - setListParams(debouncedValue); + setListParams(cachedListParams); }, SEARCH_DEBOUNCE_VALUE_MS, - [debouncedValue] + [cachedListParams] ); const deleteButton = selectedItems.length ? ( @@ -138,7 +139,7 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ if (changedQuery.ast.clauses.length > 1) { setError({ name: onlyOneClauseMessage, message: onlyOneClauseMessage }); } else { - setDebouncedValue(getListParams(listParams, changedQuery)); + setCachedListParams(getListParams(listParams, changedQuery)); } } };