From 5aa05051a9a9cfa41d81efa5f5cdceeb67901f43 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 24 Jul 2019 14:20:49 -0700 Subject: [PATCH 1/6] Add single and bulk policy delete --- .../public/app/components/index.ts | 1 + .../app/components/policy_delete_provider.tsx | 159 ++++++++++++++++++ .../public/app/constants/index.ts | 2 + .../policy_details/policy_details.tsx | 36 +++- .../sections/home/policy_list/policy_list.tsx | 18 +- .../policy_list/policy_table/policy_table.tsx | 91 +++++++++- .../repository_details/repository_details.tsx | 2 +- .../snapshot_details/snapshot_details.tsx | 2 +- .../app/services/http/policy_requests.ts | 13 +- .../server/client/elasticsearch_slm.ts | 14 ++ .../server/routes/api/policy.test.ts | 66 +++++++- .../server/routes/api/policy.ts | 30 +++- 12 files changed, 422 insertions(+), 12 deletions(-) create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_delete_provider.tsx diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts index 355805babf25c..cb3c1108a4bb8 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts @@ -14,3 +14,4 @@ export { SectionError } from './section_error'; export { SectionLoading } from './section_loading'; export { SnapshotDeleteProvider } from './snapshot_delete_provider'; export { RestoreSnapshotForm } from './restore_snapshot_form'; +export { PolicyDeleteProvider } from './policy_delete_provider'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_delete_provider.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_delete_provider.tsx new file mode 100644 index 0000000000000..25add91b17224 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_delete_provider.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useRef, useState } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { useAppDependencies } from '../index'; +import { deletePolicies } from '../services/http'; + +interface Props { + children: (deletePolicy: DeletePolicy) => React.ReactElement; +} + +export type DeletePolicy = (names: string[], onSuccess?: OnSuccessCallback) => void; + +type OnSuccessCallback = (policiesDeleted: string[]) => void; + +export const PolicyDeleteProvider: React.FunctionComponent = ({ children }) => { + const { + core: { + i18n, + notification: { toastNotifications }, + }, + } = useAppDependencies(); + const { FormattedMessage } = i18n; + const [policyNames, setPolicyNames] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const onSuccessCallback = useRef(null); + + const deletePolicyPrompt: DeletePolicy = (names, onSuccess = () => undefined) => { + if (!names || !names.length) { + throw new Error('No policy names specified for deletion'); + } + setIsModalOpen(true); + setPolicyNames(names); + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setIsModalOpen(false); + setPolicyNames([]); + }; + + const deletePolicy = () => { + const policiesToDelete = [...policyNames]; + deletePolicies(policiesToDelete).then(({ data: { itemsDeleted, errors }, error }) => { + // Surface success notifications + if (itemsDeleted && itemsDeleted.length) { + const hasMultipleSuccesses = itemsDeleted.length > 1; + const successMessage = hasMultipleSuccesses + ? i18n.translate('xpack.snapshotRestore.deletePolicy.successMultipleNotificationTitle', { + defaultMessage: 'Deleted {count} policies', + values: { count: itemsDeleted.length }, + }) + : i18n.translate('xpack.snapshotRestore.deletePolicy.successSingleNotificationTitle', { + defaultMessage: "Deleted policy '{name}'", + values: { name: itemsDeleted[0] }, + }); + toastNotifications.addSuccess(successMessage); + if (onSuccessCallback.current) { + onSuccessCallback.current([...itemsDeleted]); + } + } + + // Surface error notifications + // `error` is generic server error + // `data.errors` are specific errors with removing particular policy(ies) + if (error || (errors && errors.length)) { + const hasMultipleErrors = + (errors && errors.length > 1) || (error && policiesToDelete.length > 1); + const errorMessage = hasMultipleErrors + ? i18n.translate('xpack.snapshotRestore.deletePolicy.errorMultipleNotificationTitle', { + defaultMessage: 'Error deleting {count} policies', + values: { + count: (errors && errors.length) || policiesToDelete.length, + }, + }) + : i18n.translate('xpack.snapshotRestore.deletePolicy.errorSingleNotificationTitle', { + defaultMessage: "Error deleting policy '{name}'", + values: { name: (errors && errors[0].name) || policiesToDelete[0] }, + }); + toastNotifications.addDanger(errorMessage); + } + }); + closeModal(); + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + const isSingle = policyNames.length === 1; + + return ( + + + ) : ( + + ) + } + onCancel={closeModal} + onConfirm={deletePolicy} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="danger" + data-test-subj="srdeletePolicyConfirmationModal" + > + {!isSingle ? ( + +

+ +

+
    + {policyNames.map(name => ( +
  • {name}
  • + ))} +
+
+ ) : null} +
+
+ ); + }; + + return ( + + {children(deletePolicyPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts index f7a7ec6abebea..1a9eee6d27792 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts @@ -109,3 +109,5 @@ export const UIM_POLICY_LIST_LOAD = 'policy_list_load'; export const UIM_POLICY_SHOW_DETAILS_CLICK = 'policy_show_details_click'; export const UIM_POLICY_DETAIL_PANEL_SUMMARY_TAB = 'policy_detail_panel_summary_tab'; export const UIM_POLICY_DETAIL_PANEL_HISTORY_TAB = 'policy_detail_panel_last_success_tab'; +export const UIM_POLICY_DELETE = 'policy_delete'; +export const UIM_POLICY_DELETE_MANY = 'policy_delete_many'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx index ef8f8cf5e1774..609086f93b40c 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx @@ -26,12 +26,13 @@ import { import { useLoadPolicy } from '../../../../services/http'; import { uiMetricService } from '../../../../services/ui_metric'; -import { SectionError, SectionLoading } from '../../../../components'; +import { SectionError, SectionLoading, PolicyDeleteProvider } from '../../../../components'; import { TabSummary, TabHistory } from './tabs'; interface Props { policyName: SlmPolicy['name']; onClose: () => void; + onPolicyDeleted: (policiesDeleted: Array) => void; } const TAB_SUMMARY = 'summary'; @@ -42,7 +43,11 @@ const tabToUiMetricMap: { [key: string]: string } = { [TAB_HISTORY]: UIM_POLICY_DETAIL_PANEL_HISTORY_TAB, }; -export const PolicyDetails: React.FunctionComponent = ({ policyName, onClose }) => { +export const PolicyDetails: React.FunctionComponent = ({ + policyName, + onClose, + onPolicyDeleted, +}) => { const { core: { i18n }, } = useAppDependencies(); @@ -170,6 +175,31 @@ export const PolicyDetails: React.FunctionComponent = ({ policyName, onCl /> + + {policyDetails ? ( + + + + + {deletePolicyPrompt => { + return ( + deletePolicyPrompt([policyName], onPolicyDeleted)} + > + + + ); + }} + + + + + ) : null} ); }; @@ -180,7 +210,7 @@ export const PolicyDetails: React.FunctionComponent = ({ policyName, onCl data-test-subj="policyDetail" aria-labelledby="srPolicyDetailsFlyoutTitle" size="m" - maxWidth={400} + maxWidth={550} > diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx index ac4903442ec46..9f1c4a9ddb55b 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx @@ -53,6 +53,15 @@ export const PolicyList: React.FunctionComponent): void => { + if (policyName && policiesDeleted.includes(policyName)) { + closePolicyDetails(); + } + if (policiesDeleted.length) { + reload(); + } + }; + // Track component loaded const { trackUiMetric } = uiMetricService; useEffect(() => { @@ -113,13 +122,20 @@ export const PolicyList: React.FunctionComponent ); } return (
- {policyName ? : null} + {policyName ? ( + + ) : null} {content}
); diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx index a097f7930d124..bf16bfaf05308 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx @@ -4,31 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiLink } from '@elastic/eui'; +import React, { useState } from 'react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiLink, + EuiToolTip, + EuiButtonIcon, +} from '@elastic/eui'; import { SlmPolicy } from '../../../../../../common/types'; import { UIM_POLICY_SHOW_DETAILS_CLICK } from '../../../../constants'; import { useAppDependencies } from '../../../../index'; -import { FormattedDateTime } from '../../../../components'; +import { FormattedDateTime, PolicyDeleteProvider } from '../../../../components'; import { uiMetricService } from '../../../../services/ui_metric'; interface Props { policies: SlmPolicy[]; reload: () => Promise; openPolicyDetailsUrl: (name: SlmPolicy['name']) => string; + onPolicyDeleted: (policiesDeleted: Array) => void; } export const PolicyTable: React.FunctionComponent = ({ policies, reload, openPolicyDetailsUrl, + onPolicyDeleted, }) => { const { core: { i18n }, } = useAppDependencies(); const { FormattedMessage } = i18n; const { trackUiMetric } = uiMetricService; + const [selectedItems, setSelectedItems] = useState([]); const columns = [ { @@ -85,6 +96,45 @@ export const PolicyTable: React.FunctionComponent = ({ ), }, + { + name: i18n.translate('xpack.snapshotRestore.policyList.table.actionsColumnTitle', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: ({ name }: SlmPolicy) => { + return ( + + {deletePolicyPrompt => { + const label = i18n.translate( + 'xpack.snapshotRestore.policyList.table.actionDeleteTooltip', + { defaultMessage: 'Delete' } + ); + return ( + + deletePolicyPrompt([name], onPolicyDeleted)} + /> + + ); + }} + + ); + }, + }, + ], + width: '100px', + }, ]; const sorting = { @@ -99,7 +149,41 @@ export const PolicyTable: React.FunctionComponent = ({ pageSizeOptions: [10, 20, 50], }; + const selection = { + onSelectionChange: (newSelectedItems: SlmPolicy[]) => setSelectedItems(newSelectedItems), + }; + const search = { + toolsLeft: selectedItems.length ? ( + + {( + deletePolicyPrompt: ( + names: Array, + onSuccess?: (policiesDeleted: Array) => void + ) => void + ) => { + return ( + + deletePolicyPrompt(selectedItems.map(({ name }) => name), onPolicyDeleted) + } + color="danger" + data-test-subj="srPolicyListBulkDeleteActionButton" + > + + + ); + }} + + ) : ( + undefined + ), toolsRight: ( @@ -151,6 +235,7 @@ export const PolicyTable: React.FunctionComponent = ({ columns={columns} search={search} sorting={sorting} + selection={selection} pagination={pagination} isSelectable={true} rowProps={() => ({ diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx index 33767dcd98185..d7d5d6753a4e7 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx @@ -390,7 +390,7 @@ export const RepositoryDetails: React.FunctionComponent = ({ data-test-subj="repositoryDetail" aria-labelledby="srRepositoryDetailsFlyoutTitle" size="m" - maxWidth={400} + maxWidth={550} > diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/snapshot_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/snapshot_details.tsx index c202e210e3c77..e3534e3c89459 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/snapshot_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/snapshot_details.tsx @@ -248,7 +248,7 @@ export const SnapshotDetails: React.FunctionComponent = ({ data-test-subj="snapshotDetail" aria-labelledby="srSnapshotDetailsFlyoutTitle" size="m" - maxWidth={400} + maxWidth={550} > diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts index d7e2e42bef0dd..2ce05a33071e0 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts @@ -5,8 +5,9 @@ */ import { API_BASE_PATH } from '../../../../common/constants'; import { SlmPolicy } from '../../../../common/types'; +import { UIM_POLICY_DELETE, UIM_POLICY_DELETE_MANY } from '../../constants'; import { httpService } from './http'; -import { useRequest } from './use_request'; +import { useRequest, sendRequest } from './use_request'; export const useLoadPolicies = () => { return useRequest({ @@ -21,3 +22,13 @@ export const useLoadPolicy = (name: SlmPolicy['name']) => { method: 'get', }); }; + +export const deletePolicies = async (names: Array) => { + return sendRequest({ + path: httpService.addBasePath( + `${API_BASE_PATH}policies/${names.map(name => encodeURIComponent(name)).join(',')}` + ), + method: 'delete', + uimActionType: names.length > 1 ? UIM_POLICY_DELETE_MANY : UIM_POLICY_DELETE, + }); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts index 75a6215b68142..fa7a45eecd915 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts @@ -32,4 +32,18 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) ], method: 'GET', }); + + slm.deletePolicy = ca({ + urls: [ + { + fmt: '/_slm/policy/<%=name%>', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'DELETE', + }); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts index 9c27613279d39..70e68179851eb 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { Request, ResponseToolkit } from 'hapi'; -import { getAllHandler, getOneHandler } from './policy'; +import { getAllHandler, getOneHandler, deleteHandler } from './policy'; describe('[Snapshot and Restore API Routes] Restore', () => { const mockRequest = {} as Request; @@ -116,4 +116,68 @@ describe('[Snapshot and Restore API Routes] Restore', () => { ).rejects.toThrow(); }); }); + + describe('deleteHandler()', () => { + const names = ['fooPolicy', 'barPolicy']; + const mockCreateRequest = ({ + params: { + names: names.join(','), + }, + } as unknown) as Request; + + it('should return successful ES responses', async () => { + const mockEsResponse = { acknowledged: true }; + const callWithRequest = jest + .fn() + .mockResolvedValueOnce(mockEsResponse) + .mockResolvedValueOnce(mockEsResponse); + const expectedResponse = { itemsDeleted: names, errors: [] }; + await expect( + deleteHandler(mockCreateRequest, callWithRequest, mockResponseToolkit) + ).resolves.toEqual(expectedResponse); + }); + + it('should return error ES responses', async () => { + const mockEsError = new Error('Test error') as any; + mockEsError.response = '{}'; + mockEsError.statusCode = 500; + const callWithRequest = jest + .fn() + .mockRejectedValueOnce(mockEsError) + .mockRejectedValueOnce(mockEsError); + const expectedResponse = { + itemsDeleted: [], + errors: names.map(name => ({ + name, + error: mockEsError, + })), + }; + await expect( + deleteHandler(mockCreateRequest, callWithRequest, mockResponseToolkit) + ).resolves.toEqual(expectedResponse); + }); + + it('should return combination of ES successes and errors', async () => { + const mockEsError = new Error('Test error') as any; + mockEsError.response = '{}'; + mockEsError.statusCode = 500; + const mockEsResponse = { acknowledged: true }; + const callWithRequest = jest + .fn() + .mockRejectedValueOnce(mockEsError) + .mockResolvedValueOnce(mockEsResponse); + const expectedResponse = { + itemsDeleted: [names[1]], + errors: [ + { + name: names[0], + error: mockEsError, + }, + ], + }; + await expect( + deleteHandler(mockCreateRequest, callWithRequest, mockResponseToolkit) + ).resolves.toEqual(expectedResponse); + }); + }); }); diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts index 0a7130c6b4157..0b102301e3ed8 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts @@ -4,13 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router'; -import { wrapCustomError } from '../../../../../server/lib/create_router/error_wrappers'; +import { + wrapCustomError, + wrapEsError, +} from '../../../../../server/lib/create_router/error_wrappers'; import { SlmPolicyEs, SlmPolicy } from '../../../common/types'; import { deserializePolicy } from '../../lib'; export function registerPolicyRoutes(router: Router) { router.get('policies', getAllHandler); router.get('policy/{name}', getOneHandler); + router.delete('policies/{names}', deleteHandler); } export const getAllHandler: RouterRouteHandler = async ( @@ -59,3 +63,27 @@ export const getOneHandler: RouterRouteHandler = async ( policy: deserializePolicy(name, policiesByName[name]), }; }; + +export const deleteHandler: RouterRouteHandler = async (req, callWithRequest) => { + const { names } = req.params; + const policyNames = names.split(','); + const response: { itemsDeleted: string[]; errors: any[] } = { + itemsDeleted: [], + errors: [], + }; + + await Promise.all( + policyNames.map(name => { + return callWithRequest('slm.deletePolicy', { name }) + .then(() => response.itemsDeleted.push(name)) + .catch(e => + response.errors.push({ + name, + error: wrapEsError(e), + }) + ); + }) + ); + + return response; +}; From b9f88f4c2140c51930cd438d7b6c56bfdfe52c12 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 24 Jul 2019 15:14:06 -0700 Subject: [PATCH 2/6] Add policy execution --- .../public/app/components/index.ts | 1 + .../components/policy_execute_provider.tsx | 125 ++++++++++++++++++ .../public/app/constants/index.ts | 1 + .../policy_details/policy_details.tsx | 36 ++++- .../sections/home/policy_list/policy_list.tsx | 6 + .../policy_list/policy_table/policy_table.tsx | 39 +++++- .../app/services/http/policy_requests.ts | 10 +- .../server/client/elasticsearch_slm.ts | 14 ++ .../server/routes/api/policy.test.ts | 31 ++++- .../server/routes/api/policy.ts | 9 ++ 10 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_execute_provider.tsx diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts index cb3c1108a4bb8..a017299a78914 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts @@ -14,4 +14,5 @@ export { SectionError } from './section_error'; export { SectionLoading } from './section_loading'; export { SnapshotDeleteProvider } from './snapshot_delete_provider'; export { RestoreSnapshotForm } from './restore_snapshot_form'; +export { PolicyExecuteProvider } from './policy_execute_provider'; export { PolicyDeleteProvider } from './policy_delete_provider'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_execute_provider.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_execute_provider.tsx new file mode 100644 index 0000000000000..d7d5933f8c0f8 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_execute_provider.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useRef, useState } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { useAppDependencies } from '../index'; +import { executePolicy as executePolicyRequest } from '../services/http'; + +interface Props { + children: (executePolicy: ExecutePolicy) => React.ReactElement; +} + +export type ExecutePolicy = (name: string, onSuccess?: OnSuccessCallback) => void; + +type OnSuccessCallback = () => void; + +export const PolicyExecuteProvider: React.FunctionComponent = ({ children }) => { + const { + core: { + i18n, + notification: { toastNotifications }, + }, + } = useAppDependencies(); + const { FormattedMessage } = i18n; + const [policyName, setPolicyName] = useState(''); + const [isModalOpen, setIsModalOpen] = useState(false); + const onSuccessCallback = useRef(null); + + const executePolicyPrompt: ExecutePolicy = (name, onSuccess = () => undefined) => { + if (!name || !name.length) { + throw new Error('No policy name specified for execution'); + } + setIsModalOpen(true); + setPolicyName(name); + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setIsModalOpen(false); + setPolicyName(''); + }; + const executePolicy = () => { + executePolicyRequest(policyName).then(({ data: { snapshotName }, error }) => { + // Surface success notification + if (snapshotName) { + const successMessage = i18n.translate( + 'xpack.snapshotRestore.executePolicy.successNotificationTitle', + { + defaultMessage: "Policy '{name}' is running", + values: { name: policyName }, + } + ); + toastNotifications.addSuccess(successMessage); + if (onSuccessCallback.current) { + onSuccessCallback.current(); + } + } + + // Surface error notifications + if (error) { + const errorMessage = i18n.translate( + 'xpack.snapshotRestore.executePolicy.errorNotificationTitle', + { + defaultMessage: "Error running policy '{name}'", + values: { name: policyName }, + } + ); + toastNotifications.addDanger(errorMessage); + } + }); + closeModal(); + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + return ( + + + } + onCancel={closeModal} + onConfirm={executePolicy} + cancelButtonText={ + + } + confirmButtonText={ + + } + data-test-subj="srExecutePolicyConfirmationModal" + > +

+ +

+
+
+ ); + }; + + return ( + + {children(executePolicyPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts index 1a9eee6d27792..61722bada4d13 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts @@ -109,5 +109,6 @@ export const UIM_POLICY_LIST_LOAD = 'policy_list_load'; export const UIM_POLICY_SHOW_DETAILS_CLICK = 'policy_show_details_click'; export const UIM_POLICY_DETAIL_PANEL_SUMMARY_TAB = 'policy_detail_panel_summary_tab'; export const UIM_POLICY_DETAIL_PANEL_HISTORY_TAB = 'policy_detail_panel_last_success_tab'; +export const UIM_POLICY_EXECUTE = 'policy_execute'; export const UIM_POLICY_DELETE = 'policy_delete'; export const UIM_POLICY_DELETE_MANY = 'policy_delete_many'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx index 609086f93b40c..c6b4d61957791 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx @@ -15,6 +15,7 @@ import { EuiTitle, EuiTabs, EuiTab, + EuiButton, } from '@elastic/eui'; import { SlmPolicy } from '../../../../../../common/types'; @@ -26,13 +27,19 @@ import { import { useLoadPolicy } from '../../../../services/http'; import { uiMetricService } from '../../../../services/ui_metric'; -import { SectionError, SectionLoading, PolicyDeleteProvider } from '../../../../components'; +import { + SectionError, + SectionLoading, + PolicyExecuteProvider, + PolicyDeleteProvider, +} from '../../../../components'; import { TabSummary, TabHistory } from './tabs'; interface Props { policyName: SlmPolicy['name']; onClose: () => void; onPolicyDeleted: (policiesDeleted: Array) => void; + onPolicyExecuted: () => void; } const TAB_SUMMARY = 'summary'; @@ -47,6 +54,7 @@ export const PolicyDetails: React.FunctionComponent = ({ policyName, onClose, onPolicyDeleted, + onPolicyExecuted, }) => { const { core: { i18n }, @@ -54,7 +62,7 @@ export const PolicyDetails: React.FunctionComponent = ({ const { FormattedMessage } = i18n; const { trackUiMetric } = uiMetricService; - const { error, data: policyDetails } = useLoadPolicy(policyName); + const { error, data: policyDetails, request: reload } = useLoadPolicy(policyName); const [activeTab, setActiveTab] = useState(TAB_SUMMARY); // Reset tab when we look at a different policy @@ -197,6 +205,30 @@ export const PolicyDetails: React.FunctionComponent = ({ }}
+ + + {executePolicyPrompt => { + return ( + + executePolicyPrompt(policyName, () => { + onPolicyExecuted(); + reload(); + }) + } + fill + color="primary" + data-test-subj="srPoicyDetailsExecuteActionButton" + > + + + ); + }} + +
) : null} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx index 9f1c4a9ddb55b..2152b7896ab93 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx @@ -62,6 +62,10 @@ export const PolicyList: React.FunctionComponent { + reload(); + }; + // Track component loaded const { trackUiMetric } = uiMetricService; useEffect(() => { @@ -123,6 +127,7 @@ export const PolicyList: React.FunctionComponent ); } @@ -134,6 +139,7 @@ export const PolicyList: React.FunctionComponent ) : null} {content} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx index bf16bfaf05308..c63b42111cdf1 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx @@ -18,7 +18,11 @@ import { import { SlmPolicy } from '../../../../../../common/types'; import { UIM_POLICY_SHOW_DETAILS_CLICK } from '../../../../constants'; import { useAppDependencies } from '../../../../index'; -import { FormattedDateTime, PolicyDeleteProvider } from '../../../../components'; +import { + FormattedDateTime, + PolicyExecuteProvider, + PolicyDeleteProvider, +} from '../../../../components'; import { uiMetricService } from '../../../../services/ui_metric'; interface Props { @@ -26,6 +30,7 @@ interface Props { reload: () => Promise; openPolicyDetailsUrl: (name: SlmPolicy['name']) => string; onPolicyDeleted: (policiesDeleted: Array) => void; + onPolicyExecuted: () => void; } export const PolicyTable: React.FunctionComponent = ({ @@ -33,6 +38,7 @@ export const PolicyTable: React.FunctionComponent = ({ reload, openPolicyDetailsUrl, onPolicyDeleted, + onPolicyExecuted, }) => { const { core: { i18n }, @@ -101,6 +107,37 @@ export const PolicyTable: React.FunctionComponent = ({ defaultMessage: 'Actions', }), actions: [ + { + render: ({ name }: SlmPolicy) => { + return ( + + {executePolicyPrompt => { + const label = i18n.translate( + 'xpack.snapshotRestore.policyList.table.actionExecuteTooltip', + { defaultMessage: 'Run policy' } + ); + return ( + + executePolicyPrompt(name, onPolicyExecuted)} + /> + + ); + }} + + ); + }, + }, { render: ({ name }: SlmPolicy) => { return ( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts index 2ce05a33071e0..3b20e9568948b 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts @@ -5,7 +5,7 @@ */ import { API_BASE_PATH } from '../../../../common/constants'; import { SlmPolicy } from '../../../../common/types'; -import { UIM_POLICY_DELETE, UIM_POLICY_DELETE_MANY } from '../../constants'; +import { UIM_POLICY_EXECUTE, UIM_POLICY_DELETE, UIM_POLICY_DELETE_MANY } from '../../constants'; import { httpService } from './http'; import { useRequest, sendRequest } from './use_request'; @@ -23,6 +23,14 @@ export const useLoadPolicy = (name: SlmPolicy['name']) => { }); }; +export const executePolicy = async (name: SlmPolicy['name']) => { + return sendRequest({ + path: httpService.addBasePath(`${API_BASE_PATH}policy/${encodeURIComponent(name)}/run`), + method: 'post', + uimActionType: UIM_POLICY_EXECUTE, + }); +}; + export const deletePolicies = async (names: Array) => { return sendRequest({ path: httpService.addBasePath( diff --git a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts index fa7a45eecd915..c37cc51f67eb0 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts @@ -46,4 +46,18 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) ], method: 'DELETE', }); + + slm.executePolicy = ca({ + urls: [ + { + fmt: '/_slm/policy/<%=name%>/_execute', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'PUT', + }); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts index 70e68179851eb..f2335d4f78dd9 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { Request, ResponseToolkit } from 'hapi'; -import { getAllHandler, getOneHandler, deleteHandler } from './policy'; +import { getAllHandler, getOneHandler, executeHandler, deleteHandler } from './policy'; describe('[Snapshot and Restore API Routes] Restore', () => { const mockRequest = {} as Request; @@ -117,6 +117,35 @@ describe('[Snapshot and Restore API Routes] Restore', () => { }); }); + describe('executeHandler()', () => { + const name = 'fooPolicy'; + const mockExecuteRequest = ({ + params: { + name, + }, + } as unknown) as Request; + + it('should return snapshot name from ES', async () => { + const mockEsResponse = { + snapshot_name: 'foo-policy-snapshot', + }; + const callWithRequest = jest.fn().mockResolvedValueOnce(mockEsResponse); + const expectedResponse = { + snapshotName: 'foo-policy-snapshot', + }; + await expect( + executeHandler(mockExecuteRequest, callWithRequest, mockResponseToolkit) + ).resolves.toEqual(expectedResponse); + }); + + it('should throw if ES error', async () => { + const callWithRequest = jest.fn().mockRejectedValueOnce(new Error()); + await expect( + executeHandler(mockExecuteRequest, callWithRequest, mockResponseToolkit) + ).rejects.toThrow(); + }); + }); + describe('deleteHandler()', () => { const names = ['fooPolicy', 'barPolicy']; const mockCreateRequest = ({ diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts index 0b102301e3ed8..28b75b706bcad 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts @@ -14,6 +14,7 @@ import { deserializePolicy } from '../../lib'; export function registerPolicyRoutes(router: Router) { router.get('policies', getAllHandler); router.get('policy/{name}', getOneHandler); + router.post('policy/{name}/run', executeHandler); router.delete('policies/{names}', deleteHandler); } @@ -64,6 +65,14 @@ export const getOneHandler: RouterRouteHandler = async ( }; }; +export const executeHandler: RouterRouteHandler = async (req, callWithRequest) => { + const { name } = req.params; + const { snapshot_name: snapshotName } = await callWithRequest('slm.executePolicy', { + name, + }); + return { snapshotName }; +}; + export const deleteHandler: RouterRouteHandler = async (req, callWithRequest) => { const { names } = req.params; const policyNames = names.split(','); From caec51e5b0a81441a962de9a7acc7a6df8a16003 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 24 Jul 2019 15:28:24 -0700 Subject: [PATCH 3/6] Remove early destructuring of provider request responses --- .../public/app/components/policy_delete_provider.tsx | 4 +++- .../public/app/components/policy_execute_provider.tsx | 4 +++- .../public/app/components/repository_delete_provider.tsx | 4 +++- .../public/app/components/snapshot_delete_provider.tsx | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_delete_provider.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_delete_provider.tsx index 25add91b17224..b9265f96273d8 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_delete_provider.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_delete_provider.tsx @@ -45,7 +45,9 @@ export const PolicyDeleteProvider: React.FunctionComponent = ({ children const deletePolicy = () => { const policiesToDelete = [...policyNames]; - deletePolicies(policiesToDelete).then(({ data: { itemsDeleted, errors }, error }) => { + deletePolicies(policiesToDelete).then(({ data, error }) => { + const { itemsDeleted, errors } = data || { itemsDeleted: undefined, errors: undefined }; + // Surface success notifications if (itemsDeleted && itemsDeleted.length) { const hasMultipleSuccesses = itemsDeleted.length > 1; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_execute_provider.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_execute_provider.tsx index d7d5933f8c0f8..3df081e9c9dba 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_execute_provider.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_execute_provider.tsx @@ -43,7 +43,9 @@ export const PolicyExecuteProvider: React.FunctionComponent = ({ children setPolicyName(''); }; const executePolicy = () => { - executePolicyRequest(policyName).then(({ data: { snapshotName }, error }) => { + executePolicyRequest(policyName).then(({ data, error }) => { + const { snapshotName } = data || { snapshotName: undefined }; + // Surface success notification if (snapshotName) { const successMessage = i18n.translate( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/repository_delete_provider.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/repository_delete_provider.tsx index 049a7b3baf5bc..509eeb0201825 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/repository_delete_provider.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/repository_delete_provider.tsx @@ -49,7 +49,9 @@ export const RepositoryDeleteProvider: React.FunctionComponent = ({ child const deleteRepository = () => { const repositoriesToDelete = [...repositoryNames]; - deleteRepositories(repositoriesToDelete).then(({ data: { itemsDeleted, errors }, error }) => { + deleteRepositories(repositoriesToDelete).then(({ data, error }) => { + const { itemsDeleted, errors } = data || { itemsDeleted: undefined, errors: undefined }; + // Surface success notifications if (itemsDeleted && itemsDeleted.length) { const hasMultipleSuccesses = itemsDeleted.length > 1; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/snapshot_delete_provider.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/snapshot_delete_provider.tsx index 84a92dd01c18a..4c3d84a285b99 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/snapshot_delete_provider.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/snapshot_delete_provider.tsx @@ -61,7 +61,9 @@ export const SnapshotDeleteProvider: React.FunctionComponent = ({ childre const deleteSnapshot = () => { const snapshotsToDelete = [...snapshotIds]; setIsDeleting(true); - deleteSnapshots(snapshotsToDelete).then(({ data: { itemsDeleted, errors }, error }) => { + deleteSnapshots(snapshotsToDelete).then(({ data, error }) => { + const { itemsDeleted, errors } = data || { itemsDeleted: undefined, errors: undefined }; + // Wait until request is done to close modal; deleting snapshots take longer due to their sequential nature closeModal(); setIsDeleting(false); From 7f93d8484bc38a190617c8426ab49084c022a98c Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 31 Jul 2019 14:15:27 -0700 Subject: [PATCH 4/6] Address PR feedback --- .../home/policy_list/policy_details/policy_details.tsx | 4 ++-- .../sections/home/policy_list/policy_table/policy_table.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx index c6b4d61957791..cec6ac04c955e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx @@ -193,7 +193,7 @@ export const PolicyDetails: React.FunctionComponent = ({ return ( deletePolicyPrompt([policyName], onPolicyDeleted)} > = ({ } fill color="primary" - data-test-subj="srPoicyDetailsExecuteActionButton" + data-test-subj="srPolicyDetailsExecuteActionButton" > = ({ aria-label={i18n.translate( 'xpack.snapshotRestore.policyList.table.actionExecuteAriaLabel', { - defaultMessage: 'Run policy `{name}`', + defaultMessage: `Run policy '{name}'`, values: { name }, } )} @@ -153,7 +153,7 @@ export const PolicyTable: React.FunctionComponent = ({ aria-label={i18n.translate( 'xpack.snapshotRestore.policyList.table.actionDeleteAriaLabel', { - defaultMessage: 'Delete policy `{name}`', + defaultMessage: `Delete policy '{name}'`, values: { name }, } )} From 82a0ce83f19a0bffc80aac240c90f2753c3c99c7 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 31 Jul 2019 15:37:15 -0700 Subject: [PATCH 5/6] Adjust policy requests for useRequest changes --- .../public/app/services/http/policy_requests.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts index 3b20e9568948b..6a2c9c685a01f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/policy_requests.ts @@ -6,6 +6,7 @@ import { API_BASE_PATH } from '../../../../common/constants'; import { SlmPolicy } from '../../../../common/types'; import { UIM_POLICY_EXECUTE, UIM_POLICY_DELETE, UIM_POLICY_DELETE_MANY } from '../../constants'; +import { uiMetricService } from '../ui_metric'; import { httpService } from './http'; import { useRequest, sendRequest } from './use_request'; @@ -24,19 +25,25 @@ export const useLoadPolicy = (name: SlmPolicy['name']) => { }; export const executePolicy = async (name: SlmPolicy['name']) => { - return sendRequest({ + const result = sendRequest({ path: httpService.addBasePath(`${API_BASE_PATH}policy/${encodeURIComponent(name)}/run`), method: 'post', - uimActionType: UIM_POLICY_EXECUTE, }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_POLICY_EXECUTE); + return result; }; export const deletePolicies = async (names: Array) => { - return sendRequest({ + const result = sendRequest({ path: httpService.addBasePath( `${API_BASE_PATH}policies/${names.map(name => encodeURIComponent(name)).join(',')}` ), method: 'delete', - uimActionType: names.length > 1 ? UIM_POLICY_DELETE_MANY : UIM_POLICY_DELETE, }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(names.length > 1 ? UIM_POLICY_DELETE_MANY : UIM_POLICY_DELETE); + return result; }; From 6a6b963114f49f075a6277d66ebfd387ab188260 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 31 Jul 2019 16:30:14 -0700 Subject: [PATCH 6/6] Fix policy reload --- .../sections/home/policy_list/policy_details/policy_details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx index cec6ac04c955e..ab658373283c8 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/policy_details.tsx @@ -62,7 +62,7 @@ export const PolicyDetails: React.FunctionComponent = ({ const { FormattedMessage } = i18n; const { trackUiMetric } = uiMetricService; - const { error, data: policyDetails, request: reload } = useLoadPolicy(policyName); + const { error, data: policyDetails, sendRequest: reload } = useLoadPolicy(policyName); const [activeTab, setActiveTab] = useState(TAB_SUMMARY); // Reset tab when we look at a different policy