diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts index 372b5bc6f6529..1fb6aa8686b7e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getRepository } from '../../../test/fixtures'; +import { getRepository, getPolicy } from '../../../test/fixtures'; + export const REPOSITORY_NAME = 'my-test-repository'; export const REPOSITORY_EDIT = getRepository({ name: REPOSITORY_NAME }); + +export const POLICY_NAME = 'my-test-policy'; + +export const POLICY_EDIT = getPolicy({ name: POLICY_NAME }); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts index dd9d51a9990cc..d9f2c1b510a14 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts @@ -72,6 +72,37 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ); }; + const setLoadIndicesResponse = (response: HttpResponse = {}) => { + const defaultResponse = { indices: [] }; + + server.respondWith( + 'GET', + `${API_BASE_PATH}policies/indices`, + response + ? mockResponse(defaultResponse, response) + : [200, { 'Content-Type': 'application/json' }, ''] + ); + }; + + const setAddPolicyResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + + server.respondWith('PUT', `${API_BASE_PATH}policies`, [ + status, + { 'Content-Type': 'application/json' }, + body, + ]); + }; + + const setGetPolicyResponse = (response?: HttpResponse) => { + server.respondWith('GET', `${API_BASE_PATH}policy/:name`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + return { setLoadRepositoriesResponse, setLoadRepositoryTypesResponse, @@ -79,6 +110,9 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setSaveRepositoryResponse, setLoadSnapshotsResponse, setGetSnapshotResponse, + setLoadIndicesResponse, + setAddPolicyResponse, + setGetPolicyResponse, }; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts index d8bb3d4c25e10..e6fea41d86928 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts @@ -7,6 +7,8 @@ import { setup as homeSetup } from './home.helpers'; import { setup as repositoryAddSetup } from './repository_add.helpers'; import { setup as repositoryEditSetup } from './repository_edit.helpers'; +import { setup as policyAddSetup } from './policy_add.helpers'; +import { setup as policyEditSetup } from './policy_edit.helpers'; export { nextTick, getRandomString, findTestSubject, TestBed } from '../../../../../../test_utils'; @@ -16,4 +18,6 @@ export const pageHelpers = { home: { setup: homeSetup }, repositoryAdd: { setup: repositoryAddSetup }, repositoryEdit: { setup: repositoryEditSetup }, + policyAdd: { setup: policyAddSetup }, + policyEdit: { setup: policyEditSetup }, }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts new file mode 100644 index 0000000000000..ff59bd83dc1e8 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts @@ -0,0 +1,25 @@ +/* + * 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 { registerTestBed, TestBedConfig } from '../../../../../../test_utils'; +import { PolicyAdd } from '../../../public/app/sections/policy_add'; +import { WithProviders } from './providers'; +import { formSetup, PolicyFormTestSubjects } from './policy_form.helpers'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: ['/add_policy'], + componentRoutePath: '/add_policy', + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed( + WithProviders(PolicyAdd), + testBedConfig +); + +export const setup = formSetup.bind(null, initTestBed); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts new file mode 100644 index 0000000000000..b2c0e4242a3fd --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts @@ -0,0 +1,26 @@ +/* + * 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 { registerTestBed, TestBedConfig } from '../../../../../../test_utils'; +import { PolicyEdit } from '../../../public/app/sections/policy_edit'; +import { WithProviders } from './providers'; +import { POLICY_NAME } from './constant'; +import { formSetup, PolicyFormTestSubjects } from './policy_form.helpers'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`/edit_policy/${POLICY_NAME}`], + componentRoutePath: '/edit_policy/:name', + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed( + WithProviders(PolicyEdit), + testBedConfig +); + +export const setup = formSetup.bind(null, initTestBed); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts new file mode 100644 index 0000000000000..302af7a1ec7f0 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts @@ -0,0 +1,56 @@ +/* + * 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 { TestBed, SetupFunc } from '../../../../../../test_utils'; + +export interface PolicyFormTestBed extends TestBed { + actions: { + clickNextButton: () => void; + clickSubmitButton: () => void; + }; +} + +export const formSetup = async ( + initTestBed: SetupFunc +): Promise => { + const testBed = await initTestBed(); + + // User actions + const clickNextButton = () => { + testBed.find('nextButton').simulate('click'); + }; + + const clickSubmitButton = () => { + testBed.find('submitButton').simulate('click'); + }; + + return { + ...testBed, + actions: { + clickNextButton, + clickSubmitButton, + }, + }; +}; + +export type PolicyFormTestSubjects = + | 'advancedCronInput' + | 'allIndicesToggle' + | 'backButton' + | 'deselectIndicesLink' + | 'expireAfterValueInput' + | 'expireAfterUnitSelect' + | 'ignoreUnavailableIndicesToggle' + | 'nameInput' + | 'maxCountInput' + | 'minCountInput' + | 'nextButton' + | 'pageTitle' + | 'savePolicyApiError' + | 'selectIndicesLink' + | 'showAdvancedCronLink' + | 'snapshotNameInput' + | 'submitButton'; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx index 5257c030518ba..187d2da0d7a3d 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx @@ -10,7 +10,15 @@ import { setAppDependencies } from '../../../public/app/index'; const { core, plugins } = createShim(); const appDependencies = { - core, + core: { + ...core, + chrome: { + ...core.chrome, + // mock getInjected() to return true + // this is used so the policy tab renders (slmUiEnabled config) + getInjected: () => true, + }, + }, plugins, }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts index dcfcdb1031dd5..e914f06d8e16f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts @@ -9,11 +9,15 @@ import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { i18n } from '@kbn/i18n'; +import { docTitle } from 'ui/doc_title/doc_title'; import { httpService } from '../../../public/app/services/http'; -import { breadcrumbService } from '../../../public/app/services/navigation'; +import { breadcrumbService, docTitleService } from '../../../public/app/services/navigation'; import { textService } from '../../../public/app/services/text'; import { chrome } from '../../../public/test/mocks'; import { init as initHttpRequests } from './http_requests'; +import { uiMetricService } from '../../../public/app/services/ui_metric'; +import { documentationLinksService } from '../../../public/app/services/documentation'; +import { createUiStatsReporter } from '../../../../../../../src/legacy/core_plugins/ui_metric/public'; export const setupEnvironment = () => { httpService.init(axios.create({ adapter: axiosXhrAdapter }), { @@ -21,6 +25,9 @@ export const setupEnvironment = () => { }); breadcrumbService.init(chrome, {}); textService.init(i18n); + uiMetricService.init(createUiStatsReporter); + documentationLinksService.init('', ''); + docTitleService.init(docTitle.change); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts index 1cbafab69da7c..7f4860e74bafe 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts @@ -17,6 +17,7 @@ import { } from './helpers'; import { HomeTestBed } from './helpers/home.helpers'; import { REPOSITORY_NAME } from './helpers/constant'; +import moment from 'moment-timezone'; const { setup } = pageHelpers.home; @@ -51,7 +52,7 @@ describe.skip('', () => { test('should set the correct app title', () => { const { exists, find } = testBed; expect(exists('appTitle')).toBe(true); - expect(find('appTitle').text()).toEqual('Snapshot Repositories'); + expect(find('appTitle').text()).toEqual('Snapshot and Restore'); }); test('should display a loading while fetching the repositories', () => { @@ -63,7 +64,7 @@ describe.skip('', () => { test('should have a link to the documentation', () => { const { exists, find } = testBed; expect(exists('documentationLink')).toBe(true); - expect(find('documentationLink').text()).toBe('Snapshot docs'); + expect(find('documentationLink').text()).toBe('Snapshot and Restore docs'); }); describe('tabs', () => { @@ -77,14 +78,19 @@ describe.skip('', () => { }); }); - test('should have 2 tabs', () => { + test('should have 4 tabs', () => { const { find } = testBed; - expect(find('tab').length).toBe(2); - expect(find('tab').map(t => t.text())).toEqual(['Snapshots', 'Repositories']); + expect(find('tab').length).toBe(4); + expect(find('tab').map(t => t.text())).toEqual([ + 'Snapshots', + 'Repositories', + 'Policies', + 'Restore Status', + ]); }); - test('should navigate to snapshot list tab', () => { + test('should navigate to snapshot list tab', async () => { const { exists, actions } = testBed; expect(exists('repositoryList')).toBe(true); @@ -92,6 +98,12 @@ describe.skip('', () => { actions.selectTab('snapshots'); + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBed.component.update(); + }); + expect(exists('repositoryList')).toBe(false); expect(exists('snapshotList')).toBe(true); }); @@ -264,6 +276,11 @@ describe.skip('', () => { expect(exists('repositoryDetail')).toBe(false); await actions.clickRepositoryAt(0); + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBed.component.update(); + }); expect(exists('repositoryDetail')).toBe(true); }); @@ -454,14 +471,19 @@ describe.skip('', () => { const { tableCellsValues } = table.getMetaData('snapshotTable'); tableCellsValues.forEach((row, i) => { const snapshot = snapshots[i]; + const startTime = moment(new Date(snapshot.startTimeInMillis)); + const timezone = moment.tz.guess(); + expect(row).toEqual([ + '', // Checkbox snapshot.snapshot, // Snapshot REPOSITORY_NAME, // Repository - 'foo', // TODO: fix this with FormattedDateTime value - `${Math.ceil(snapshot.durationInMillis / 1000).toString()}s`, // Duration snapshot.indices.length.toString(), // Indices snapshot.shards.total.toString(), // Shards snapshot.shards.failed.toString(), // Failed shards + startTime.tz(timezone).format('MMMM D, YYYY h:mm A z'), // Start time + `${Math.ceil(snapshot.durationInMillis / 1000).toString()}s`, // Duration + '', ]); }); }); @@ -590,22 +612,38 @@ describe.skip('', () => { describe('summary tab', () => { test('should set the correct summary values', () => { + const { + version, + versionId, + uuid, + indices, + endTimeInMillis, + startTimeInMillis, + } = snapshot1; + const { find } = testBed; + const startTime = moment(new Date(startTimeInMillis)); + const endTime = moment(new Date(endTimeInMillis)); + const timezone = moment.tz.guess(); expect(find('snapshotDetail.version.value').text()).toBe( - `${snapshot1.version} / ${snapshot1.versionId}` + `${version} / ${versionId}` ); - expect(find('snapshotDetail.uuid.value').text()).toBe(snapshot1.uuid); + expect(find('snapshotDetail.uuid.value').text()).toBe(uuid); expect(find('snapshotDetail.state.value').text()).toBe('Snapshot complete'); expect(find('snapshotDetail.includeGlobalState.value').text()).toBe('Yes'); expect(find('snapshotDetail.indices.title').text()).toBe( - `Indices (${snapshot1.indices.length})` + `Indices (${indices.length})` + ); + expect(find('snapshotDetail.indices.value').text()).toContain( + indices.splice(0, 10).join('') + ); + expect(find('snapshotDetail.startTime.value').text()).toBe( + startTime.tz(timezone).format('MMMM D, YYYY h:mm A z') ); - expect(find('snapshotDetail.indices.value').text()).toBe( - snapshot1.indices.join('') + expect(find('snapshotDetail.endTime.value').text()).toBe( + endTime.tz(timezone).format('MMMM D, YYYY h:mm A z') ); - expect(find('snapshotDetail.startTime.value').text()).toBe('foo'); // TODO: fix this with FormattedDateTime value - expect(find('snapshotDetail.endTime.value').text()).toBe('foo'); // TODO: fix this with FormattedDateTime value }); test('should indicate the different snapshot states', async () => { @@ -647,7 +685,7 @@ describe.skip('', () => { [SNAPSHOT_STATE.INCOMPATIBLE]: 'Incompatible version ', }; - // Call sequencially each state and verify that the message is ok + // Call sequentially each state and verify that the message is ok return Object.entries(mapStateToMessage).reduce((promise, [state, message]) => { return promise.then(async () => expectMessageForSnapshotState(state, message)); }, Promise.resolve()); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts new file mode 100644 index 0000000000000..19feb85e4f04e --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts @@ -0,0 +1,215 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import * as fixtures from '../../test/fixtures'; + +import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; +import { PolicyFormTestBed } from './helpers/policy_form.helpers'; +import { DEFAULT_POLICY_SCHEDULE } from '../../public/app/constants'; + +const { setup } = pageHelpers.policyAdd; + +jest.mock('ui/i18n', () => { + const I18nContext = ({ children }: any) => children; + return { I18nContext }; +}); + +const POLICY_NAME = 'my_policy'; +const SNAPSHOT_NAME = 'my_snapshot'; +const MIN_COUNT = '5'; +const MAX_COUNT = '10'; +const EXPIRE_AFTER_VALUE = '30'; +const repository = fixtures.getRepository({ name: `a${getRandomString()}`, type: 'fs' }); + +// We need to skip the tests until react 16.9.0 is released +// which supports asynchronous code inside act() +describe.skip('', () => { + let testBed: PolicyFormTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('on component mount', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadRepositoriesResponse({ repositories: [repository] }); + httpRequestsMockHelpers.setLoadIndicesResponse({ indices: ['my_index'] }); + + testBed = await setup(); + await nextTick(); + testBed.component.update(); + }); + + test('should set the correct page title', () => { + const { exists, find } = testBed; + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Create policy'); + }); + + test('should not let the user go to the next step if required fields are missing', () => { + const { find } = testBed; + + expect(find('nextButton').props().disabled).toBe(true); + }); + + describe('form validation', () => { + describe('logistics (step 1)', () => { + test('should require a policy name', async () => { + const { form, find } = testBed; + + form.setInputValue('nameInput', ''); + find('nameInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Policy name is required.']); + }); + + test('should require a snapshot name', () => { + const { form, find } = testBed; + + form.setInputValue('snapshotNameInput', ''); + find('snapshotNameInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Snapshot name is required.']); + }); + + it('should require a schedule', () => { + const { form, find } = testBed; + + find('showAdvancedCronLink').simulate('click'); + form.setInputValue('advancedCronInput', ''); + find('advancedCronInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Schedule is required.']); + }); + }); + + describe('snapshot settings (step 2)', () => { + beforeEach(() => { + const { form, actions } = testBed; + // Complete step 1 + form.setInputValue('nameInput', POLICY_NAME); + form.setInputValue('snapshotNameInput', SNAPSHOT_NAME); + actions.clickNextButton(); + }); + + test('should require at least one index', async () => { + const { find, form, component } = testBed; + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + // Toggle "All indices" switch + form.toggleEuiSwitch('allIndicesToggle', false); + await nextTick(); + component.update(); + }); + + // Deselect all indices from list + find('deselectIndicesLink').simulate('click'); + + expect(form.getErrorsMessages()).toEqual(['You must select at least one index.']); + }); + }); + + describe('retention (step 3)', () => { + beforeEach(() => { + const { form, actions } = testBed; + // Complete step 1 + form.setInputValue('nameInput', POLICY_NAME); + form.setInputValue('snapshotNameInput', SNAPSHOT_NAME); + actions.clickNextButton(); + + // Complete step 2 + actions.clickNextButton(); + }); + + test('should not allow the minimum count be greater than the maximum count', () => { + const { find, form } = testBed; + + form.setInputValue('minCountInput', MAX_COUNT + 1); + find('minCountInput').simulate('blur'); + + form.setInputValue('maxCountInput', MAX_COUNT); + find('maxCountInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Min count cannot be greater than max count.']); + }); + }); + }); + + describe('form payload & api errors', () => { + beforeEach(async () => { + const { actions, form } = testBed; + + // Complete step 1 + form.setInputValue('nameInput', POLICY_NAME); + form.setInputValue('snapshotNameInput', SNAPSHOT_NAME); + actions.clickNextButton(); + + // Complete step 2 + actions.clickNextButton(); + + // Complete step 3 + form.setInputValue('expireAfterValueInput', EXPIRE_AFTER_VALUE); + form.setInputValue('minCountInput', MIN_COUNT); + form.setInputValue('maxCountInput', MAX_COUNT); + actions.clickNextButton(); + }); + + it('should send the correct payload', async () => { + const { actions } = testBed; + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + config: {}, + name: POLICY_NAME, + repository: repository.name, + retention: { + expireAfterUnit: 'd', // default + expireAfterValue: Number(EXPIRE_AFTER_VALUE), + maxCount: Number(MAX_COUNT), + minCount: Number(MIN_COUNT), + }, + schedule: DEFAULT_POLICY_SCHEDULE, + snapshotName: SNAPSHOT_NAME, + }; + + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + + it('should surface the API errors from the put HTTP request', async () => { + const { component, actions, find, exists } = testBed; + + const error = { + status: 409, + error: 'Conflict', + message: `There is already a policy with name '${POLICY_NAME}'`, + }; + + httpRequestsMockHelpers.setAddPolicyResponse(undefined, { body: error }); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + component.update(); + }); + + expect(exists('savePolicyApiError')).toBe(true); + expect(find('savePolicyApiError').text()).toContain(error.message); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts new file mode 100644 index 0000000000000..efcb338e6d268 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts @@ -0,0 +1,136 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { PolicyForm } from '../../public/app/components/policy_form'; +import { PolicyFormTestBed } from './helpers/policy_form.helpers'; +import { POLICY_EDIT } from './helpers/constant'; + +const { setup } = pageHelpers.policyEdit; +const { setup: setupPolicyAdd } = pageHelpers.policyAdd; + +const EXPIRE_AFTER_VALUE = '5'; +const EXPIRE_AFTER_UNIT = 'm'; + +jest.mock('ui/i18n', () => { + const I18nContext = ({ children }: any) => children; + return { I18nContext }; +}); + +// We need to skip the tests until react 16.9.0 is released +// which supports asynchronous code inside act() +describe.skip('', () => { + let testBed: PolicyFormTestBed; + let testBedPolicyAdd: PolicyFormTestBed; + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('on component mount', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setGetPolicyResponse({ policy: POLICY_EDIT }); + httpRequestsMockHelpers.setLoadIndicesResponse({ indices: ['my_index'] }); + httpRequestsMockHelpers.setLoadRepositoriesResponse({ + repositories: [{ name: POLICY_EDIT.repository }], + }); + + testBed = await setup(); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBed.component.update(); + }); + }); + + test('should set the correct page title', () => { + const { exists, find } = testBed; + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Edit policy'); + }); + + /** + * As the "edit" policy component uses the same form underneath that + * the "create" policy, we won't test it again but simply make sure that + * the same form component is indeed shared between the 2 app sections. + */ + test('should use the same Form component as the "" section', async () => { + testBedPolicyAdd = await setupPolicyAdd(); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBedPolicyAdd.component.update(); + }); + + const formEdit = testBed.component.find(PolicyForm); + const formAdd = testBedPolicyAdd.component.find(PolicyForm); + + expect(formEdit.length).toBe(1); + expect(formAdd.length).toBe(1); + }); + + test('should disable the policy name field', () => { + const { find } = testBed; + + const nameInput = find('nameInput'); + expect(nameInput.props().disabled).toEqual(true); + }); + + describe('form payload', () => { + beforeEach(async () => { + const { form, actions } = testBed; + + const { snapshotName } = POLICY_EDIT; + + // Complete step 1 + form.setInputValue('snapshotNameInput', `${snapshotName}-edited`); + actions.clickNextButton(); + + // Complete step 2 + // console.log(testBed.component.debug()); + form.toggleEuiSwitch('ignoreUnavailableIndicesToggle'); + actions.clickNextButton(); + + // Complete step 3 + form.setInputValue('expireAfterValueInput', EXPIRE_AFTER_VALUE); + form.setInputValue('expireAfterUnitSelect', EXPIRE_AFTER_UNIT); + actions.clickNextButton(); + }); + + it('should send the correct payload with changed values', async () => { + const { actions } = testBed; + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + ...POLICY_EDIT, + ...{ + config: { + ignoreUnavailable: true, + }, + retention: { + expireAfterValue: Number(EXPIRE_AFTER_VALUE), + expireAfterUnit: EXPIRE_AFTER_UNIT, + }, + snapshotName: `${POLICY_EDIT.snapshotName}-edited`, + }, + }; + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/snapshot_restore/common/constants.ts b/x-pack/legacy/plugins/snapshot_restore/common/constants.ts index a881bf3081c5e..f04a5d6dc6e75 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/constants.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/constants.ts @@ -54,3 +54,10 @@ export const APP_REQUIRED_CLUSTER_PRIVILEGES = [ ]; export const APP_RESTORE_INDEX_PRIVILEGES = ['monitor']; export const APP_SLM_CLUSTER_PRIVILEGES = ['manage_slm']; + +export const TIME_UNITS: { [key: string]: 'd' | 'h' | 'm' | 's' } = { + DAY: 'd', + HOUR: 'h', + MINUTE: 'm', + SECOND: 's', +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts index bede2689bb855..579dae0265939 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts @@ -12,5 +12,7 @@ export { deserializeSnapshotDetails, deserializeSnapshotConfig, serializeSnapshotConfig, + deserializeSnapshotRetention, + serializeSnapshotRetention, } from './snapshot_serialization'; export { deserializePolicy, serializePolicy } from './policy_serialization'; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts index 86adde4db7f99..9ce9367bc0e0e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { deserializePolicy } from './policy_serialization'; +import { deserializePolicy, serializePolicy } from './policy_serialization'; describe('repository_serialization', () => { describe('deserializePolicy()', () => { @@ -25,6 +25,11 @@ describe('repository_serialization', () => { foo: 'bar', }, }, + retention: { + expire_after: '14d', + max_count: 30, + min_count: 4, + }, }, next_execution: '2019-07-11T01:30:00.000Z', next_execution_millis: 1562722200000, @@ -45,6 +50,12 @@ describe('repository_serialization', () => { foo: 'bar', }, }, + retention: { + expireAfterValue: 14, + expireAfterUnit: 'd', + maxCount: 30, + minCount: 4, + }, nextExecution: '2019-07-11T01:30:00.000Z', nextExecutionMillis: 1562722200000, }); @@ -112,4 +123,48 @@ describe('repository_serialization', () => { }); }); }); + + describe('serializePolicy()', () => { + it('should serialize a slm policy', () => { + expect( + serializePolicy({ + name: 'my-snapshot-policy', + snapshotName: 'my-backups-snapshots', + schedule: '0 30 1 * * ?', + repository: 'my-backups', + config: { + indices: ['kibana-*'], + includeGlobalState: false, + ignoreUnavailable: false, + metadata: { + foo: 'bar', + }, + }, + retention: { + expireAfterValue: 14, + expireAfterUnit: 'd', + maxCount: 30, + minCount: 4, + }, + }) + ).toEqual({ + name: 'my-backups-snapshots', + schedule: '0 30 1 * * ?', + repository: 'my-backups', + config: { + indices: ['kibana-*'], + include_global_state: false, + ignore_unavailable: false, + metadata: { + foo: 'bar', + }, + }, + retention: { + expire_after: '14d', + max_count: 30, + min_count: 4, + }, + }); + }); + }); }); diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts index dc52765670540..4652ac4bc5cc4 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ import { SlmPolicy, SlmPolicyEs, SlmPolicyPayload } from '../types'; -import { deserializeSnapshotConfig, serializeSnapshotConfig } from './'; +import { + deserializeSnapshotConfig, + serializeSnapshotConfig, + deserializeSnapshotRetention, + serializeSnapshotRetention, +} from './'; export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolicy => { const { version, modified_date: modifiedDate, modified_date_millis: modifiedDateMillis, - policy: { name: snapshotName, schedule, repository, config }, + policy: { name: snapshotName, schedule, repository, config, retention }, next_execution: nextExecution, next_execution_millis: nextExecutionMillis, last_failure: lastFailure, last_success: lastSuccess, in_progress: inProgress, + stats, } = esPolicy; const policy: SlmPolicy = { @@ -35,6 +41,10 @@ export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolic policy.config = deserializeSnapshotConfig(config); } + if (retention) { + policy.retention = deserializeSnapshotRetention(retention); + } + if (lastFailure) { const { snapshot_name: failureSnapshotName, @@ -82,11 +92,27 @@ export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolic }; } + if (stats) { + const { + snapshots_taken: snapshotsTaken, + snapshots_failed: snapshotsFailed, + snapshots_deleted: snapshotsDeleted, + snapshot_deletion_failures: snapshotDeletionFailures, + } = stats; + + policy.stats = { + snapshotsTaken, + snapshotsFailed, + snapshotsDeleted, + snapshotDeletionFailures, + }; + } + return policy; }; export const serializePolicy = (policy: SlmPolicyPayload): SlmPolicyEs['policy'] => { - const { snapshotName: name, schedule, repository, config } = policy; + const { snapshotName: name, schedule, repository, config, retention } = policy; const policyEs: SlmPolicyEs['policy'] = { name, schedule, @@ -97,5 +123,13 @@ export const serializePolicy = (policy: SlmPolicyPayload): SlmPolicyEs['policy'] policyEs.config = serializeSnapshotConfig(config); } + if (retention) { + const serializedRetention = serializeSnapshotRetention(retention); + + if (serializedRetention) { + policyEs.retention = serializeSnapshotRetention(retention); + } + } + return policyEs; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts index b1f6d2005a2e3..50fdef4175787 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts @@ -6,7 +6,16 @@ import { sortBy } from 'lodash'; -import { SnapshotDetails, SnapshotDetailsEs, SnapshotConfig, SnapshotConfigEs } from '../types'; +import { + SnapshotDetails, + SnapshotDetailsEs, + SnapshotConfig, + SnapshotConfigEs, + SnapshotRetention, + SnapshotRetentionEs, +} from '../types'; + +import { deserializeTime, serializeTime } from './time_serialization'; export function deserializeSnapshotDetails( repository: string, @@ -128,3 +137,68 @@ export function serializeSnapshotConfig(snapshotConfig: SnapshotConfig): Snapsho return config; }, {}); } + +export function deserializeSnapshotRetention( + snapshotRetentionEs: SnapshotRetentionEs +): SnapshotRetention { + const { + expire_after: expireAfter, + max_count: maxCount, + min_count: minCount, + } = snapshotRetentionEs; + + let expireAfterValue; + let expireAfterUnit; + + if (expireAfter) { + const { timeValue, timeUnit } = deserializeTime(expireAfter); + + if (timeValue && timeUnit) { + expireAfterValue = timeValue; + expireAfterUnit = timeUnit; + } + } + + const snapshotRetention: SnapshotRetention = { + expireAfterValue, + expireAfterUnit, + maxCount, + minCount, + }; + + return Object.entries(snapshotRetention).reduce((retention: any, [key, value]) => { + if (value !== undefined) { + retention[key] = value; + } + return retention; + }, {}); +} + +export function serializeSnapshotRetention( + snapshotRetention: SnapshotRetention +): SnapshotRetentionEs | undefined { + const { expireAfterValue, expireAfterUnit, minCount, maxCount } = snapshotRetention; + + const snapshotRetentionEs: SnapshotRetentionEs = { + expire_after: + expireAfterValue && expireAfterUnit + ? serializeTime(expireAfterValue, expireAfterUnit) + : undefined, + min_count: !minCount ? undefined : minCount, + max_count: !maxCount ? undefined : maxCount, + }; + + const flattenedSnapshotRetentionEs = Object.entries(snapshotRetentionEs).reduce( + (retention: any, [key, value]) => { + if (value !== undefined) { + retention[key] = value; + } + return retention; + }, + {} + ); + + return Object.entries(flattenedSnapshotRetentionEs).length + ? flattenedSnapshotRetentionEs + : undefined; +} diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts new file mode 100644 index 0000000000000..f661c0c585852 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts @@ -0,0 +1,29 @@ +/* + * 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 { deserializeTime, serializeTime } from './time_serialization'; +import { TIME_UNITS } from '../constants'; + +describe('time_serialization', () => { + describe('deserializeTime()', () => { + it('should deserialize valid ES time', () => { + Object.values(TIME_UNITS).forEach(unit => { + expect(deserializeTime(`15${unit}`)).toEqual({ + timeValue: 15, + timeUnit: unit, + }); + }); + }); + it('should return an empty object if time unit is invalid', () => { + expect(deserializeTime('15foobar')).toEqual({}); + expect(deserializeTime('15minutes')).toEqual({}); + }); + }); + describe('serializeTime()', () => { + it('should serialize ES time', () => { + expect(serializeTime(15, 'd')).toEqual('15d'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts new file mode 100644 index 0000000000000..5f65ec861e81b --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts @@ -0,0 +1,33 @@ +/* + * 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 { TIME_UNITS } from '../constants'; + +export const deserializeTime = (time: string) => { + const timeUnits = Object.values(TIME_UNITS); + + const timeUnit = timeUnits.find(unit => { + const unitIndex = time.indexOf(unit); + return unitIndex !== -1 && unitIndex === time.length - 1; + }); + + if (timeUnit) { + const timeValue = Number(time.replace(timeUnit, '')); + + if (!isNaN(timeValue)) { + return { + timeValue, + timeUnit, + }; + } + } + + return {}; +}; + +export const serializeTime = (timeValue: number, timeUnit: string) => { + return `${timeValue}${timeUnit}`; // e.g., '15d' +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts index 888cad13d213b..ed67b1eb77063 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotConfig, SnapshotConfigEs } from './snapshot'; - +import { + SnapshotConfig, + SnapshotConfigEs, + SnapshotRetention, + SnapshotRetentionEs, +} from './snapshot'; export interface SlmPolicyPayload { name: string; snapshotName: string; schedule: string; repository: string; config?: SnapshotConfig; + retention?: SnapshotRetention; } export interface SlmPolicy extends SlmPolicyPayload { @@ -34,6 +39,12 @@ export interface SlmPolicy extends SlmPolicyPayload { inProgress?: { snapshotName: string; }; + stats?: { + snapshotsTaken: number; + snapshotsFailed: number; + snapshotsDeleted: number; + snapshotDeletionFailures: number; + }; } export interface SlmPolicyEs { @@ -45,6 +56,7 @@ export interface SlmPolicyEs { schedule: string; repository: string; config?: SnapshotConfigEs; + retention?: SnapshotRetentionEs; }; next_execution: string; next_execution_millis: number; @@ -66,4 +78,10 @@ export interface SlmPolicyEs { start_time: string; start_time_millis: number; }; + stats?: { + snapshots_taken: number; + snapshots_failed: number; + snapshots_deleted: number; + snapshot_deletion_failures: number; + }; } diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts index dd561bd50d352..46713c937fd3f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts @@ -79,3 +79,16 @@ interface SnapshotDetailsShardsStatusEs { failed: number; successful: number; } + +export interface SnapshotRetention { + expireAfterValue?: number | ''; + expireAfterUnit?: string; + maxCount?: number | ''; + minCount?: number | ''; +} + +export interface SnapshotRetentionEs { + expire_after?: string; + max_count?: number; + min_count?: number; +} 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 a367e529cf63b..a1f3d3554a266 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 @@ -16,4 +16,8 @@ export { SnapshotDeleteProvider } from './snapshot_delete_provider'; export { RestoreSnapshotForm } from './restore_snapshot_form'; export { PolicyExecuteProvider } from './policy_execute_provider'; export { PolicyDeleteProvider } from './policy_delete_provider'; +export { + UpdateRetentionModalProvider, + UpdateRetentionSetting, +} from './update_retention_modal_provider'; export { PolicyForm } from './policy_form'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx index ba9877a9e9f41..6bb376b9298ed 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx @@ -41,14 +41,23 @@ export const PolicyNavigation: React.FunctionComponent = ({ onClick: () => updateCurrentStep(2), }, { - title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepReviewName', { - defaultMessage: 'Review', + title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepRetentionName', { + defaultMessage: 'Snapshot retention', }), - isComplete: maxCompletedStep >= 2, + isComplete: maxCompletedStep >= 3, isSelected: currentStep === 3, disabled: maxCompletedStep < 2, onClick: () => updateCurrentStep(3), }, + { + title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepReviewName', { + defaultMessage: 'Review', + }), + isComplete: maxCompletedStep >= 3, + isSelected: currentStep === 4, + disabled: maxCompletedStep < 3, + onClick: () => updateCurrentStep(4), + }, ]; return ; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx index 6c631ab8e6c69..7e55cee63a0ac 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx @@ -13,9 +13,15 @@ import { EuiSpacer, } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; +import { TIME_UNITS } from '../../../../common/constants'; import { PolicyValidation, validatePolicy } from '../../services/validation'; import { useAppDependencies } from '../../index'; -import { PolicyStepLogistics, PolicyStepSettings, PolicyStepReview } from './steps'; +import { + PolicyStepLogistics, + PolicyStepSettings, + PolicyStepRetention, + PolicyStepReview, +} from './steps'; import { PolicyNavigation } from './navigation'; interface Props { @@ -53,7 +59,8 @@ export const PolicyForm: React.FunctionComponent = ({ const stepMap: { [key: number]: any } = { 1: PolicyStepLogistics, 2: PolicyStepSettings, - 3: PolicyStepReview, + 3: PolicyStepRetention, + 4: PolicyStepReview, }; const CurrentStepForm = stepMap[currentStep]; @@ -63,6 +70,11 @@ export const PolicyForm: React.FunctionComponent = ({ config: { ...(originalPolicy.config || {}), }, + retention: { + ...(originalPolicy.retention || { + expireAfterUnit: TIME_UNITS.DAY, + }), + }, }); // Policy validation state @@ -161,7 +173,9 @@ export const PolicyForm: React.FunctionComponent = ({ fill iconType="arrowRight" onClick={() => onNext()} + iconSide="right" disabled={!validation.isValid} + data-test-subj="nextButton" > = ({ iconType="check" onClick={() => savePolicy()} isLoading={isSaving} + data-test-subj="submitButton" > {isSaving ? ( = ({ }} onBlur={() => setTouched({ ...touched, schedule: true })} placeholder={DEFAULT_POLICY_SCHEDULE} - data-test-subj="snapshotNameInput" + data-test-subj="advancedCronInput" /> diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx new file mode 100644 index 0000000000000..b32d579650134 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx @@ -0,0 +1,238 @@ +/* + * 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, useState } from 'react'; + +import { + EuiDescribedFormGroup, + EuiTitle, + EuiFormRow, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiFieldNumber, + EuiSelect, +} from '@elastic/eui'; + +import { SlmPolicyPayload } from '../../../../../common/types'; +import { TIME_UNITS } from '../../../../../common/constants'; +import { documentationLinksService } from '../../../services/documentation'; +import { useAppDependencies } from '../../../index'; +import { StepProps } from './'; +import { textService } from '../../../services/text'; + +const getExpirationTimeOptions = (unitSize = '0') => + Object.entries(TIME_UNITS).map(([_key, value]) => ({ + text: textService.getTimeUnitLabel(value, unitSize), + value, + })); + +export const PolicyStepRetention: React.FunctionComponent = ({ + policy, + updatePolicy, + errors, +}) => { + const { + core: { i18n }, + } = useAppDependencies(); + const { FormattedMessage } = i18n; + + const { retention = {} } = policy; + + const updatePolicyRetention = (updatedFields: Partial): void => { + const newRetention = { ...retention, ...updatedFields }; + updatePolicy({ + retention: newRetention, + }); + }; + + // State for touched inputs + const [touched, setTouched] = useState({ + expireAfterValue: false, + minCount: false, + maxCount: false, + }); + + const renderExpireAfterField = () => ( + +

+ +

+ + } + description={ + + } + idAria="expirationDescription" + fullWidth + > + + } + describedByIds={['expirationDescription']} + isInvalid={touched.expireAfterValue && Boolean(errors.expireAfterValue)} + error={errors.expireAfter} + fullWidth + > + + + setTouched({ ...touched, expireAfterValue: true })} + onChange={e => { + const value = e.target.value; + updatePolicyRetention({ + expireAfterValue: value !== '' ? Number(value) : value, + }); + }} + data-test-subj="expireAfterValueInput" + /> + + + { + updatePolicyRetention({ + expireAfterUnit: e.target.value, + }); + }} + data-test-subj="expireAfterUnitSelect" + /> + + + +
+ ); + + const renderCountFields = () => ( + +

+ +

+ + } + description={ + + } + idAria="countDescription" + fullWidth + > + + + + } + describedByIds={['countDescription']} + isInvalid={touched.minCount && Boolean(errors.minCount)} + error={errors.minCount} + fullWidth + > + setTouched({ ...touched, minCount: true })} + onChange={e => { + const value = e.target.value; + updatePolicyRetention({ + minCount: value !== '' ? Number(value) : value, + }); + }} + data-test-subj="minCountInput" + /> + + + + + } + describedByIds={['countDescription']} + error={errors.maxCount} + fullWidth + > + setTouched({ ...touched, maxCount: true })} + onChange={e => { + const value = e.target.value; + updatePolicyRetention({ + maxCount: value !== '' ? Number(value) : value, + }); + }} + data-test-subj="maxCountInput" + /> + + + +
+ ); + + return ( + + {/* Step title and doc link */} + + + +

+ +

+
+
+ + + + + + +
+ + {renderExpireAfterField()} + {renderCountFields()} +
+ ); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx index 2599aa4b19bb1..b2f9a4231e853 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx @@ -13,11 +13,11 @@ import { EuiDescriptionListDescription, EuiSpacer, EuiTabbedContent, + EuiText, EuiTitle, EuiLink, EuiIcon, EuiToolTip, - EuiText, } from '@elastic/eui'; import { serializePolicy } from '../../../../../common/lib'; import { useAppDependencies } from '../../../index'; @@ -31,7 +31,7 @@ export const PolicyStepReview: React.FunctionComponent = ({ core: { i18n }, } = useAppDependencies(); const { FormattedMessage } = i18n; - const { name, snapshotName, schedule, repository, config } = policy; + const { name, snapshotName, schedule, repository, config, retention } = policy; const { indices, includeGlobalState, ignoreUnavailable, partial } = config || { indices: undefined, includeGlobalState: undefined, @@ -48,8 +48,27 @@ export const PolicyStepReview: React.FunctionComponent = ({ const hiddenIndicesCount = displayIndices && displayIndices.length > 10 ? displayIndices.length - 10 : 0; + const serializedPolicy = serializePolicy(policy); + const { retention: serializedRetention } = serializedPolicy; + + const EditStepTooltip = ({ step }: { step: number }) => ( + + } + > + updateCurrentStep(step)}> + + + + ); + const renderSummaryTab = () => ( + {/* Logistics summary */}

@@ -57,18 +76,7 @@ export const PolicyStepReview: React.FunctionComponent = ({ id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.sectionLogisticsTitle" defaultMessage="Logistics" />{' '} - - } - > - updateCurrentStep(1)}> - - - +

@@ -125,24 +133,15 @@ export const PolicyStepReview: React.FunctionComponent = ({ + + {/* Snapshot settings summary */}

{' '} - - } - > - updateCurrentStep(2)}> - - - +

@@ -279,12 +278,69 @@ export const PolicyStepReview: React.FunctionComponent = ({ + + {/* Retention summary */} + {serializedRetention ? ( + + + +

+ {' '} + +

+
+ + + + {retention!.expireAfterValue && ( + + + + + + {retention!.expireAfterValue} + {retention!.expireAfterUnit} + + + )} + {retention!.minCount && ( + + + + + {retention!.minCount} + + )} + {retention!.maxCount && ( + + + + + {retention!.maxCount} + + )} + +
+ ) : null}
); const renderRequestTab = () => { const endpoint = `PUT _slm/policy/${name}`; - const json = JSON.stringify(serializePolicy(policy), null, 2); + const json = JSON.stringify(serializedPolicy, null, 2); + return ( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx index 642440a8c5e91..6f1b2ed2cef4d 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx @@ -109,6 +109,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ /> } checked={isAllIndices} + data-test-subj="allIndicesToggle" onChange={e => { const isChecked = e.target.checked; setIsAllIndices(isChecked); @@ -162,6 +163,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ { setSelectIndicesMode('list'); updatePolicyConfig({ indices: indicesSelection }); @@ -186,6 +188,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ selectOrDeselectAllLink: config.indices && config.indices.length > 0 ? ( { // TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed indicesOptions.forEach((option: Option) => { @@ -313,6 +316,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ fullWidth > = ({ > } @@ -397,6 +402,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ fullWidth > React.ReactElement; +} + +export type UpdateRetentionSetting = ( + retentionSchedule?: string, + onSuccess?: OnSuccessCallback +) => void; + +type OnSuccessCallback = () => void; + +export const UpdateRetentionModalProvider: React.FunctionComponent = ({ children }) => { + const { + core: { + i18n, + notification: { toastNotifications }, + }, + } = useAppDependencies(); + const { FormattedMessage } = i18n; + + const [retentionSchedule, setRetentionSchedule] = useState(DEFAULT_RETENTION_SCHEDULE); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [isAdvancedCronVisible, setIsAdvancedCronVisible] = useState(false); + + const onSuccessCallback = useRef(null); + + const [simpleCron, setSimpleCron] = useState<{ + expression: string; + frequency: string; + }>({ + expression: DEFAULT_RETENTION_SCHEDULE, + frequency: DEFAULT_RETENTION_FREQUENCY, + }); + + const [fieldToPreferredValueMap, setFieldToPreferredValueMap] = useState({}); + + const [isInvalid, setIsInvalid] = useState(false); + + const updateRetentionPrompt: UpdateRetentionSetting = ( + originalRetentionSchedule, + onSuccess = () => undefined + ) => { + setIsModalOpen(true); + + setIsAdvancedCronVisible( + Boolean(originalRetentionSchedule && originalRetentionSchedule !== DEFAULT_RETENTION_SCHEDULE) + ); + + if (originalRetentionSchedule) { + setIsEditing(true); + setRetentionSchedule(originalRetentionSchedule); + } + + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setIsModalOpen(false); + }; + + const updateRetentionSetting = async () => { + if (!retentionSchedule) { + setIsInvalid(true); + return; + } + + setIsSaving(true); + setSaveError(null); + + const { error } = await updateRetentionSchedule(retentionSchedule); + + setIsSaving(false); + + if (error) { + setSaveError(error); + } else { + closeModal(); + + toastNotifications.addSuccess( + i18n.translate( + 'xpack.snapshotRestore.policyForm.stepRetention.policyUpdateRetentionSuccessMessage', + { + defaultMessage: 'Updated retention schedule', + } + ) + ); + + if (onSuccessCallback.current) { + onSuccessCallback.current(); + } + } + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + return ( + + + + + {isEditing ? ( + + ) : ( + + )} + + + + + {saveError && ( + + + } + color="danger" + iconType="alert" + > + {saveError.data && saveError.data.message ? ( +

{saveError.data.message}

+ ) : null} +
+ +
+ )} + {isAdvancedCronVisible ? ( + + + } + isInvalid={isInvalid} + error={i18n.translate( + 'xpack.snapshotRestore.policyForm.stepRetention.policyUpdateRetentionScheduleFieldErrorMessage', + { + defaultMessage: 'Retention schedule is required.', + } + )} + helpText={ + + +
+ ), + }} + /> + } + fullWidth + > + setRetentionSchedule(e.target.value)} + /> + + + + + + { + setIsAdvancedCronVisible(false); + setRetentionSchedule(simpleCron.expression); + }} + data-test-subj="showBasicCronLink" + > + + + +
+ ) : ( + + { + setSimpleCron({ + expression, + frequency, + }); + setFieldToPreferredValueMap(newFieldToPreferredValueMap); + setRetentionSchedule(expression); + }} + /> + + + + + { + setIsAdvancedCronVisible(true); + }} + data-test-subj="showAdvancedCronLink" + > + + + + + )} + + + + + + + + + + + + + + ); + }; + + return ( + + {children(updateRetentionPrompt)} + {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 d95c243aeed62..56da4d8a50972 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 @@ -91,6 +91,9 @@ export const REMOVE_INDEX_SETTINGS_SUGGESTIONS: string[] = INDEX_SETTING_SUGGEST export const DEFAULT_POLICY_SCHEDULE = '0 30 1 * * ?'; export const DEFAULT_POLICY_FREQUENCY = DAY; +export const DEFAULT_RETENTION_SCHEDULE = '0 30 1 * * ?'; +export const DEFAULT_RETENTION_FREQUENCY = DAY; + // UI Metric constants export const UIM_APP_NAME = 'snapshot_restore'; export const UIM_REPOSITORY_LIST_LOAD = 'repository_list_load'; @@ -119,3 +122,4 @@ export const UIM_POLICY_DELETE = 'policy_delete'; export const UIM_POLICY_DELETE_MANY = 'policy_delete_many'; export const UIM_POLICY_CREATE = 'policy_create'; export const UIM_POLICY_UPDATE = 'policy_update'; +export const UIM_POLICY_RETENTION_SETTINGS_UPDATE = 'policy_retention_settings_update'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx index ea29d6492cb4b..68dc9fb164c70 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx @@ -3,7 +3,7 @@ * 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, { useState, useEffect } from 'react'; +import React, { useState, useEffect, Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -14,6 +14,10 @@ import { EuiDescriptionListDescription, EuiIcon, EuiText, + EuiPanel, + EuiStat, + EuiSpacer, + EuiHorizontalRule, } from '@elastic/eui'; import { SlmPolicy } from '../../../../../../../common/types'; @@ -40,6 +44,8 @@ export const TabSummary: React.FunctionComponent = ({ policy }) => { schedule, nextExecutionMillis, config, + stats, + retention, } = policy; const { includeGlobalState, ignoreUnavailable, indices, partial } = config || { includeGlobalState: undefined, @@ -123,176 +129,306 @@ export const TabSummary: React.FunctionComponent = ({ policy }) => { }, []); return ( - - - - - - - - - {version} - - - - - - - - - - - - - - - - - - - - - - {snapshotName} - - - - - - - - - - {repository} - - - - - - - - - - - - {schedule} - - - - - - - - - - - - - - - - - - - - - - {isShowingFullIndicesList ? fullIndicesList : shortIndicesList} - - - - - - - - - - {ignoreUnavailable ? ( + + {/** Stats panel */} + {stats && ( + + + + + + + + + + + + + + + + + + + + + )} + + {/** General description list */} + +

+ +

+
+ + + + + + - ) : ( + + + + {version} + + + + + - )} -
-
-
- - - - - - - - - {partial ? ( + + + + + + + + + + + - ) : ( + + + + {snapshotName} + + + + + - )} - - - - - - - - - - {includeGlobalState === false ? ( + + + + {repository} + + + + + + + + + + + + {schedule} + + + + + + + + + + + + + + + + + - ) : ( + + + + {isShowingFullIndicesList ? fullIndicesList : shortIndicesList} + + + + + + + + + + {ignoreUnavailable ? ( + + ) : ( + + )} + + + + + + + + + + + {partial ? ( + + ) : ( + + )} + + + + + + + + + + {includeGlobalState === false ? ( + + ) : ( + + )} + + + +
+ + {retention && ( + + + + + {/** Retention description list */} + +

+ +

+
+ + + + {retention.expireAfterValue && ( + + + + + + {retention.expireAfterValue} + {retention.expireAfterUnit} + + + )} + {retention.minCount && ( + + + + + {retention.minCount} + + )} + {retention.maxCount && ( + + + + + {retention.maxCount} + )} - - - - + +
+ )} + ); }; 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 a4664ea414526..6dec1e04e2515 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 @@ -13,13 +13,14 @@ import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../common/constants'; import { SectionError, SectionLoading } from '../../../components'; import { BASE_PATH, UIM_POLICY_LIST_LOAD } from '../../../constants'; import { useAppDependencies } from '../../../index'; -import { useLoadPolicies } from '../../../services/http'; +import { useLoadPolicies, useLoadRetentionSettings } from '../../../services/http'; import { uiMetricService } from '../../../services/ui_metric'; import { linkToAddPolicy, linkToPolicy } from '../../../services/navigation'; import { WithPrivileges, NotAuthorizedSection } from '../../../lib/authorization'; import { PolicyDetails } from './policy_details'; import { PolicyTable } from './policy_table'; +import { PolicyRetentionSchedule } from './policy_retention_schedule'; interface MatchParams { policyName?: SlmPolicy['name']; @@ -46,6 +47,14 @@ export const PolicyList: React.FunctionComponent { return linkToPolicy(newPolicyName); }; @@ -137,6 +146,8 @@ export const PolicyList: React.FunctionComponent policy.schedule); const hasDuplicateSchedules = policySchedules.length > new Set(policySchedules).size; + const hasRetention = Boolean(policies.find((policy: SlmPolicy) => policy.retention)); + content = ( {hasDuplicateSchedules ? ( @@ -159,6 +170,16 @@ export const PolicyList: React.FunctionComponent ) : null} + + {hasRetention ? ( + + ) : null} + void; + isLoading: boolean; + error: any; +} + +export const PolicyRetentionSchedule: React.FunctionComponent = ({ + retentionSettings, + onRetentionScheduleUpdated, + isLoading, + error, +}) => { + const { + core: { i18n }, + } = useAppDependencies(); + + const { FormattedMessage } = i18n; + + if (isLoading) { + return ( + + + + + + + ); + } + + if (error) { + return ( + + + } + color="danger" + iconType="alert" + > + {error.data && error.data.message ?

{error.data.message}

: null} + + + +
+ +
+ ); + } + + if (retentionSettings && retentionSettings.retentionSchedule) { + const { retentionSchedule } = retentionSettings; + + return ( + + + + + +

+ {retentionSchedule} }} + /> +

+
+
+ + + {(updateRetentionPrompt: UpdateRetentionSetting) => { + return ( + + } + > + + updateRetentionPrompt(retentionSchedule, onRetentionScheduleUpdated) + } + aria-label={i18n.translate( + 'xpack.snapshotRestore.policyRetentionSchedulePanel.retentionScheduleEditLinkAriaLabel', + { + defaultMessage: 'Edit retention schedule', + } + )} + /> + + ); + }} + + +
+
+ +
+ ); + } else { + return ( + + + } + color="warning" + iconType="alert" + > +

+ +

+ + {(updateRetentionPrompt: UpdateRetentionSetting) => { + return ( + updateRetentionPrompt(undefined, onRetentionScheduleUpdated)} + > + + + ); + }} + +
+ +
+ ); + } +}; 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 cb2ced1411c47..8ce5f46daf284 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 @@ -146,6 +146,24 @@ export const PolicyTable: React.FunctionComponent = ({ truncateText: true, sortable: true, }, + { + field: 'retention', + name: i18n.translate('xpack.snapshotRestore.policyList.table.retentionColumnTitle', { + defaultMessage: 'Retention', + }), + render: (retention: SlmPolicy['retention']) => + retention ? ( + + ) : null, + }, { field: 'nextExecutionMillis', name: i18n.translate('xpack.snapshotRestore.policyList.table.nextExecutionColumnTitle', { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx index 1af3cfb4d133e..ea2b8b9904d8f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx @@ -70,8 +70,8 @@ export const TabFailures: React.SFC = ({ indexFailures, snapshotState })

- - {status}: {reason} + + {`${status}: ${reason}`} {failuresCount < failures.length - 1 ? : undefined} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx index 3f186dad142bb..0547e811d4617 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx @@ -8,12 +8,13 @@ import { RouteComponentProps } from 'react-router-dom'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; +import { TIME_UNITS } from '../../../../common/constants'; import { PolicyForm, SectionError, SectionLoading } from '../../components'; import { useAppDependencies } from '../../index'; import { BASE_PATH, DEFAULT_POLICY_SCHEDULE } from '../../constants'; import { breadcrumbService, docTitleService } from '../../services/navigation'; -import { addPolicy, useLoadIndicies } from '../../services/http'; +import { addPolicy, useLoadIndices } from '../../services/http'; export const PolicyAdd: React.FunctionComponent = ({ history, @@ -33,7 +34,7 @@ export const PolicyAdd: React.FunctionComponent = ({ data: { indices } = { indices: [], }, - } = useLoadIndicies(); + } = useLoadIndices(); // Set breadcrumb and page title useEffect(() => { @@ -64,6 +65,12 @@ export const PolicyAdd: React.FunctionComponent = ({ schedule: DEFAULT_POLICY_SCHEDULE, repository: '', config: {}, + retention: { + expireAfterValue: '', + expireAfterUnit: TIME_UNITS.DAY, + maxCount: '', + minCount: '', + }, }; const renderSaveError = () => { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx index 4ada745062c6f..eabb2d71754ea 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx @@ -3,17 +3,18 @@ * 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, { useEffect, useState, Fragment } from 'react'; +import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; +import { TIME_UNITS } from '../../../../common/constants'; import { SectionError, SectionLoading, PolicyForm } from '../../components'; import { BASE_PATH } from '../../constants'; import { useAppDependencies } from '../../index'; import { breadcrumbService, docTitleService } from '../../services/navigation'; -import { editPolicy, useLoadPolicy, useLoadIndicies } from '../../services/http'; +import { editPolicy, useLoadPolicy, useLoadIndices } from '../../services/http'; interface MatchParams { name: string; @@ -44,6 +45,12 @@ export const PolicyEdit: React.FunctionComponent - - + ); }; @@ -195,7 +200,7 @@ export const PolicyEdit: React.FunctionComponent -

+

{ }); }; -export const useLoadIndicies = () => { +export const useLoadIndices = () => { return useRequest({ path: httpService.addBasePath(`${API_BASE_PATH}policies/indices`), method: 'get', @@ -86,3 +87,24 @@ export const editPolicy = async (editedPolicy: SlmPolicyPayload) => { trackUiMetric(UIM_POLICY_UPDATE); return result; }; + +export const useLoadRetentionSettings = () => { + return useRequest({ + path: httpService.addBasePath(`${API_BASE_PATH}policies/retention_settings`), + method: 'get', + }); +}; + +export const updateRetentionSchedule = (retentionSchedule: string) => { + const result = sendRequest({ + path: httpService.addBasePath(`${API_BASE_PATH}policies/retention_settings`), + method: 'put', + body: { + retentionSchedule, + }, + }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_POLICY_RETENTION_SETTINGS_UPDATE); + return result; +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts index ec92250373a05..e3b5b0115d687 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { REPOSITORY_TYPES } from '../../../../common/constants'; +import { REPOSITORY_TYPES, TIME_UNITS } from '../../../../common/constants'; class TextService { public breadcrumbs: { [key: string]: string } = {}; @@ -112,6 +112,31 @@ class TextService { }, }); } + + public getTimeUnitLabel(timeUnit: 'd' | 'h' | 'm' | 's', timeValue: string) { + switch (timeUnit) { + case TIME_UNITS.SECOND: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.secondLabel', { + defaultMessage: '{timeValue, plural, one {second} other {seconds}}', + values: { timeValue }, + }); + case TIME_UNITS.MINUTE: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.minuteLabel', { + defaultMessage: '{timeValue, plural, one {minute} other {minutes}}', + values: { timeValue }, + }); + case TIME_UNITS.HOUR: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.hourLabel', { + defaultMessage: '{timeValue, plural, one {hour} other {hours}}', + values: { timeValue }, + }); + case TIME_UNITS.DAY: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.dayLabel', { + defaultMessage: '{timeValue, plural, one {day} other {days}}', + values: { timeValue }, + }); + } + } } export const textService = new TextService(); diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts index 53c62da97bdac..8a60740b1610c 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts @@ -18,7 +18,7 @@ const isStringEmpty = (str: string | null): boolean => { export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { const i18n = textService.i18n; - const { name, snapshotName, schedule, repository, config } = policy; + const { name, snapshotName, schedule, repository, config, retention } = policy; const validation: PolicyValidation = { isValid: true, @@ -28,12 +28,13 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { schedule: [], repository: [], indices: [], + minCount: [], }, }; if (isStringEmpty(name)) { validation.errors.name.push( - i18n.translate('xpack.snapshotRestore.policyValidation.nameRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.nameRequiredErroMessage', { defaultMessage: 'Policy name is required.', }) ); @@ -41,7 +42,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (isStringEmpty(snapshotName)) { validation.errors.snapshotName.push( - i18n.translate('xpack.snapshotRestore.policyValidation.snapshotNameRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.snapshotNameRequiredErrorMessage', { defaultMessage: 'Snapshot name is required.', }) ); @@ -49,7 +50,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (isStringEmpty(schedule)) { validation.errors.schedule.push( - i18n.translate('xpack.snapshotRestore.policyValidation.scheduleRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.scheduleRequiredErrorMessage', { defaultMessage: 'Schedule is required.', }) ); @@ -57,7 +58,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (isStringEmpty(repository)) { validation.errors.repository.push( - i18n.translate('xpack.snapshotRestore.policyValidation.repositoryRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.repositoryRequiredErrorMessage', { defaultMessage: 'Repository is required.', }) ); @@ -65,7 +66,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (config && typeof config.indices === 'string' && config.indices.trim().length === 0) { validation.errors.indices.push( - i18n.translate('xpack.snapshotRestore.policyValidation.indexPatternRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.indexPatternRequiredErrorMessage', { defaultMessage: 'At least one index pattern is required.', }) ); @@ -73,12 +74,24 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (config && Array.isArray(config.indices) && config.indices.length === 0) { validation.errors.indices.push( - i18n.translate('xpack.snapshotRestore.policyValidation.indicesRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.indicesRequiredErrorMessage', { defaultMessage: 'You must select at least one index.', }) ); } + if ( + retention && + retention.minCount && + retention.maxCount && + retention.minCount > retention.maxCount + ) { + validation.errors.minCount.push( + i18n.translate('xpack.snapshotRestore.policyValidation.invalidMinCountErrorMessage', { + defaultMessage: 'Min count cannot be greater than max count.', + }) + ); + } // Remove fields with no errors validation.errors = Object.entries(validation.errors) .filter(([key, value]) => value.length > 0) diff --git a/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts b/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts index 10c7a86d640e6..77db8dd993c2e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts @@ -39,11 +39,7 @@ export class Plugin { textService.init(i18n); breadcrumbService.init(chrome, management.constants.BREADCRUMB); uiMetricService.init(uiMetric.createUiStatsReporter); - documentationLinksService.init( - documentation.esDocBasePath, - documentation.esPluginDocBasePath, - documentation.esStackOverviewDocBasePath - ); + documentationLinksService.init(documentation.esDocBasePath, documentation.esPluginDocBasePath); docTitleService.init(docTitle.change); const unmountReactApp = (): void => { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/shim.ts b/x-pack/legacy/plugins/snapshot_restore/public/shim.ts index 02574890afffd..595edbfd1cea4 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/shim.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/shim.ts @@ -53,7 +53,6 @@ export interface Core extends AppCore { documentation: { esDocBasePath: string; esPluginDocBasePath: string; - esStackOverviewDocBasePath: string; }; docTitle: { change: typeof docTitle.change; @@ -113,7 +112,6 @@ export function createShim(): { core: Core; plugins: Plugins } { documentation: { esDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`, esPluginDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`, - esStackOverviewDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elastic-stack-overview/${DOC_LINK_VERSION}/`, }, docTitle: { change: docTitle.change, 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 52e6449559bcc..c0016a4f643cd 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 @@ -12,9 +12,10 @@ import { createHandler, updateHandler, getIndicesHandler, + updateRetentionSettingsHandler, } from './policy'; -describe('[Snapshot and Restore API Routes] Restore', () => { +describe('[Snapshot and Restore API Routes] Policy', () => { const mockRequest = {} as Request; const mockResponseToolkit = {} as ResponseToolkit; const mockEsPolicy = { @@ -25,6 +26,11 @@ describe('[Snapshot and Restore API Routes] Restore', () => { schedule: '0 30 1 * * ?', repository: 'my-backups', config: {}, + retention: { + expire_after: '15d', + min_count: 5, + max_count: 10, + }, }, next_execution_millis: 1562722200000, }; @@ -35,6 +41,12 @@ describe('[Snapshot and Restore API Routes] Restore', () => { schedule: '0 30 1 * * ?', repository: 'my-backups', config: {}, + retention: { + expireAfterValue: 15, + expireAfterUnit: 'd', + minCount: 5, + maxCount: 10, + }, nextExecutionMillis: 1562722200000, }; @@ -323,4 +335,29 @@ describe('[Snapshot and Restore API Routes] Restore', () => { ).rejects.toThrow(); }); }); + + describe('updateRetentionSettingsHandler()', () => { + const retentionSettings = { + retentionSchedule: '0 30 1 * * ?', + }; + const mockCreateRequest = ({ + payload: retentionSettings, + } as unknown) as Request; + + it('should return successful ES response', async () => { + const mockEsResponse = { acknowledged: true }; + const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse); + const expectedResponse = { ...mockEsResponse }; + await expect( + updateRetentionSettingsHandler(mockCreateRequest, callWithRequest, mockResponseToolkit) + ).resolves.toEqual(expectedResponse); + }); + + it('should throw if ES error', async () => { + const callWithRequest = jest.fn().mockRejectedValueOnce(new Error()); + await expect( + updateRetentionSettingsHandler(mockCreateRequest, callWithRequest, mockResponseToolkit) + ).rejects.toThrow(); + }); + }); }); 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 ed16a44bccdc6..ef9e48190a5b7 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 @@ -10,8 +10,12 @@ import { } from '../../../../../server/lib/create_router/error_wrappers'; import { SlmPolicyEs, SlmPolicy, SlmPolicyPayload } from '../../../common/types'; import { deserializePolicy, serializePolicy } from '../../../common/lib'; +import { Plugins } from '../../../shim'; -export function registerPolicyRoutes(router: Router) { +let callWithInternalUser: any; + +export function registerPolicyRoutes(router: Router, plugins: Plugins) { + callWithInternalUser = plugins.elasticsearch.getCluster('data').callWithInternalUser; router.get('policies', getAllHandler); router.get('policy/{name}', getOneHandler); router.post('policy/{name}/run', executeHandler); @@ -19,10 +23,12 @@ export function registerPolicyRoutes(router: Router) { router.put('policies', createHandler); router.put('policies/{name}', updateHandler); router.get('policies/indices', getIndicesHandler); + router.get('policies/retention_settings', getRetentionSettingsHandler); + router.put('policies/retention_settings', updateRetentionSettingsHandler); } export const getAllHandler: RouterRouteHandler = async ( - req, + _req, callWithRequest ): Promise<{ policies: SlmPolicy[]; @@ -144,7 +150,7 @@ export const updateHandler: RouterRouteHandler = async (req, callWithRequest) => }; export const getIndicesHandler: RouterRouteHandler = async ( - req, + _req, callWithRequest ): Promise<{ indices: string[]; @@ -161,3 +167,38 @@ export const getIndicesHandler: RouterRouteHandler = async ( indices: indices.map(({ index }) => index).sort(), }; }; + +export const getRetentionSettingsHandler: RouterRouteHandler = async (): Promise< + | { + [key: string]: string; + } + | undefined +> => { + const { persistent, transient, defaults } = await callWithInternalUser('cluster.getSettings', { + filterPath: '**.slm.retention*', + includeDefaults: true, + }); + const { slm: retentionSettings = undefined } = { + ...defaults, + ...persistent, + ...transient, + }; + + const { retention_schedule: retentionSchedule } = retentionSettings; + + return { retentionSchedule }; +}; + +export const updateRetentionSettingsHandler: RouterRouteHandler = async (req, callWithRequest) => { + const { retentionSchedule } = req.payload as { retentionSchedule: string }; + + return await callWithRequest('cluster.putSettings', { + body: { + persistent: { + slm: { + retention_schedule: retentionSchedule, + }, + }, + }, + }); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts index 5a76f1c268138..11a6cad86640e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts @@ -20,6 +20,6 @@ export const registerRoutes = (router: Router, plugins: Plugins): void => { registerRestoreRoutes(router); if (isSlmEnabled) { - registerPolicyRoutes(router); + registerPolicyRoutes(router, plugins); } }; diff --git a/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts index 1e744a96d81cf..f3f2f0faa744d 100644 --- a/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts @@ -6,3 +6,4 @@ export * from './repository'; export * from './snapshot'; +export * from './policy'; diff --git a/x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts new file mode 100644 index 0000000000000..3dc5f78c42457 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts @@ -0,0 +1,48 @@ +/* + * 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 { getRandomString, getRandomNumber } from '../../../../../test_utils'; +import { SlmPolicy } from '../../common/types'; +import { DEFAULT_POLICY_SCHEDULE } from '../../public/app/constants'; + +const dateNow = new Date(); +const randomModifiedDateMillis = new Date().setDate(dateNow.getDate() - 1); +const randomExecutionDateMillis = new Date().setDate(dateNow.getDate() + 1); + +const DEFAULT_STATS: SlmPolicy['stats'] = { + snapshotsTaken: 0, + snapshotsFailed: 0, + snapshotsDeleted: 0, + snapshotDeletionFailures: 0, +}; + +export const getPolicy = ({ + name = `policy-${getRandomString()}`, + config = {}, + modifiedDate = new Date(randomModifiedDateMillis).toString(), + modifiedDateMillis = randomModifiedDateMillis, + nextExecution = new Date(randomExecutionDateMillis).toString(), + nextExecutionMillis = randomExecutionDateMillis, + repository = `repo-${getRandomString()}`, + retention = {}, + schedule = DEFAULT_POLICY_SCHEDULE, + snapshotName = `snapshot-${getRandomString()}`, + stats = DEFAULT_STATS, + version = getRandomNumber(), +}: Partial = {}): SlmPolicy => ({ + name, + config, + modifiedDate, + modifiedDateMillis, + nextExecution, + nextExecutionMillis, + repository, + retention, + schedule, + snapshotName, + stats, + version, +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index af96f6eeb7bad..22f861a132063 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10801,7 +10801,6 @@ "xpack.snapshotRestore.policyForm.stepSettings.indicesToggleListLink": "インデックスを選択", "xpack.snapshotRestore.policyForm.stepSettings.partialDescription": "利用不可能なプライマリシャードのインデックスのスナップショットを許可します。これが設定されていない場合、スナップショット全体がエラーになります。", "xpack.snapshotRestore.policyForm.stepSettings.partialDescriptionTitle": "部分インデックスを許可", - "xpack.snapshotRestore.policyForm.stepSettings.partialLabel": "部分インデックスを許可", "xpack.snapshotRestore.policyForm.stepSettings.policyIncludeGlobalStateLabel": "グローバルステータスを含める", "xpack.snapshotRestore.policyForm.stepSettings.selectAllIndicesLink": "すべて選択", "xpack.snapshotRestore.policyForm.stepSettings.selectIndicesHelpText": "{count} 件の{count, plural, one {インデックス} other {インデックス}}がバックアップされます。{selectOrDeselectAllLink}", @@ -10833,12 +10832,6 @@ "xpack.snapshotRestore.policyList.table.snapshotNameColumnTitle": "スナップショット名", "xpack.snapshotRestore.policyScheduleWarningDescription": "一度に 1 つのスナップショットしか撮影できません。スナップショットのエラーを避けるために、ポリシーを編集または削除してください。", "xpack.snapshotRestore.policyScheduleWarningTitle": "2 つ以上のポリシーに同じスケジュールが設定されています", - "xpack.snapshotRestore.policyValidation.indexPatternRequiredError": "インデックスパターンが最低 1 つ必要です。", - "xpack.snapshotRestore.policyValidation.indicesRequiredError": "1 つ以上のインデックスを選択する必要があります。", - "xpack.snapshotRestore.policyValidation.nameRequiredError": "ポリシー名が必要です。", - "xpack.snapshotRestore.policyValidation.repositoryRequiredError": "レポジトリが必要です。", - "xpack.snapshotRestore.policyValidation.scheduleRequiredError": "スケジュールが必要です。", - "xpack.snapshotRestore.policyValidation.snapshotNameRequiredError": "スナップショット名が必要です。", "xpack.snapshotRestore.repositories.breadcrumbTitle": "レポジトリ", "xpack.snapshotRestore.repositoryList.table.typeFilterLabel": "タイプ", "xpack.snapshotRestore.restoreForm.stepLogistics.indicesPatternLabel": "インデックスパターン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7aa1095d227ce..68e13c2a436a9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10803,7 +10803,6 @@ "xpack.snapshotRestore.policyForm.stepSettings.indicesToggleListLink": "选择索引", "xpack.snapshotRestore.policyForm.stepSettings.partialDescription": "允许具有不可用主分片的索引的快照。否则,整个快照将失败。", "xpack.snapshotRestore.policyForm.stepSettings.partialDescriptionTitle": "允许部分索引", - "xpack.snapshotRestore.policyForm.stepSettings.partialLabel": "允许部分索引", "xpack.snapshotRestore.policyForm.stepSettings.policyIncludeGlobalStateLabel": "包括全局状态", "xpack.snapshotRestore.policyForm.stepSettings.selectAllIndicesLink": "全选", "xpack.snapshotRestore.policyForm.stepSettings.selectIndicesHelpText": "将备份 {count} 个 {count, plural, one {索引} other {索引}}。{selectOrDeselectAllLink}", @@ -10835,12 +10834,6 @@ "xpack.snapshotRestore.policyList.table.snapshotNameColumnTitle": "快照名称", "xpack.snapshotRestore.policyScheduleWarningDescription": "一次仅可以拍取一个快照。要避免快照失败,请编辑或删除策略。", "xpack.snapshotRestore.policyScheduleWarningTitle": "两个或更多策略有相同的计划", - "xpack.snapshotRestore.policyValidation.indexPatternRequiredError": "至少需要一个索引模式。", - "xpack.snapshotRestore.policyValidation.indicesRequiredError": "必须至少选择一个索引。", - "xpack.snapshotRestore.policyValidation.nameRequiredError": "策略名称必填。", - "xpack.snapshotRestore.policyValidation.repositoryRequiredError": "存储库必填。", - "xpack.snapshotRestore.policyValidation.scheduleRequiredError": "计划必填。", - "xpack.snapshotRestore.policyValidation.snapshotNameRequiredError": "快照名称必填。", "xpack.snapshotRestore.repositories.breadcrumbTitle": "存储库", "xpack.snapshotRestore.repositoryList.table.typeFilterLabel": "类型", "xpack.snapshotRestore.restoreForm.stepLogistics.indicesPatternLabel": "索引模式", diff --git a/x-pack/test_utils/testbed/types.ts b/x-pack/test_utils/testbed/types.ts index 31140c21cb530..b9ced88f3774c 100644 --- a/x-pack/test_utils/testbed/types.ts +++ b/x-pack/test_utils/testbed/types.ts @@ -85,7 +85,7 @@ export interface TestBed { * * @param switchTestSubject The test subject of the EuiSwitch (can be a nested path. e.g. "myForm.mySwitch"). */ - toggleEuiSwitch: (switchTestSubject: T) => void; + toggleEuiSwitch: (switchTestSubject: T, isChecked?: boolean) => void; /** * The EUI ComboBox is a special input as it needs the ENTER key to be pressed * in order to register the value set. This helpers automatically does that.