diff --git a/x-pack/index.js b/x-pack/index.js index 374cef7d26b66..94bfca7424b5a 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -21,6 +21,7 @@ import { apm } from './plugins/apm'; import { licenseManagement } from './plugins/license_management'; import { cloud } from './plugins/cloud'; import { indexManagement } from './plugins/index_management'; +import { indexLifecycleManagement } from './plugins/index_lifecycle_management'; import { consoleExtensions } from './plugins/console_extensions'; import { spaces } from './plugins/spaces'; import { notifications } from './plugins/notifications'; @@ -52,6 +53,7 @@ module.exports = function (kibana) { indexManagement(kibana), consoleExtensions(kibana), notifications(kibana), + indexLifecycleManagement(kibana), kueryAutocomplete(kibana), infra(kibana), rollup(kibana), diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap new file mode 100644 index 0000000000000..426e2683370ca --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap @@ -0,0 +1,975 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ilm banner extension should return extension when any index has lifecycle error 1`] = ` +Object { + "filter": Query { + "ast": _AST { + "_clauses": Array [ + Object { + "field": "ilm.step", + "match": "must", + "operator": "eq", + "type": "field", + "value": "ERROR", + }, + ], + "_indexedClauses": Object { + "field": Object { + "ilm.step": Array [ + Object { + "field": "ilm.step", + "match": "must", + "operator": "eq", + "type": "field", + "value": "ERROR", + }, + ], + }, + "is": Object {}, + "term": Array [], + }, + }, + "syntax": Object { + "parse": [Function], + "print": [Function], + }, + "text": "ilm.step:ERROR", + }, + "filterLabel": "Show errors", + "title": "1 + index has + lifecycle errors", + "type": "warning", +} +`; + +exports[`ilm filter extension should return extension when any index has lifecycle policy 1`] = ` +Array [ + Object { + "field": "ilm.managed", + "multiSelect": false, + "name": "Lifecycle status", + "options": Array [ + Object { + "value": true, + "view": "Managed", + }, + Object { + "value": false, + "view": "Unmanaged", + }, + ], + "type": "field_value_selection", + }, + Object { + "field": "ilm.phase", + "multiSelect": "or", + "name": "Lifecycle phase", + "options": Array [ + Object { + "value": "hot", + "view": "Hot", + }, + Object { + "value": "warm", + "view": "Warm", + }, + Object { + "value": "cold", + "view": "Cold", + }, + Object { + "value": "delete", + "view": "Delete", + }, + ], + "type": "field_value_selection", + }, +] +`; + +exports[`ilm summary extension should return extension when index has lifecycle error 1`] = ` + + +

+ + Index lifecycle management + +

+
+ +
+ + + } + > +
+
+ + + + Index lifecycle error + + +
+ +
+ illegal_argument_exception + : + setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined + +
+ + + + + } + closePopover={[Function]} + hasArrow={true} + id="stackPopover" + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + +
+
+ + + +
+
+
+
+
+ +
+ + +
+ + +
+ +
+ +
+ +
+ + Lifecycle policy + +
+
+ +
+ + + testy + + +
+
+ +
+ + Next action + +
+
+ +
+ rollover +
+
+ +
+ + Failed step + +
+
+ +
+ check-rollover-ready +
+
+
+
+
+
+ +
+ +
+ +
+ + Current phase + +
+
+ +
+ hot +
+
+ +
+ + Next action time + +
+
+ +
+ 1544187775891 +
+
+ + + + } + closePopover={[Function]} + hasArrow={true} + id="phaseExecutionPopover" + isOpen={false} + key="phaseExecutionPopover" + ownFocus={false} + panelPaddingSize="m" + withTitle={true} + > + +
+
+ + + +
+
+
+
+
+
+
+
+
+
+ +`; + +exports[`ilm summary extension should return extension when index has lifecycle policy 1`] = ` + + +

+ + Index lifecycle management + +

+
+ +
+ + +
+ +
+ +
+ +
+ + Lifecycle policy + +
+
+ +
+ + + testy + + +
+
+ +
+ + Next action + +
+
+ +
+ complete +
+
+ +
+ + Failed step + +
+
+ +
+ - +
+
+
+
+
+
+ +
+ +
+ +
+ + Current phase + +
+
+ +
+ new +
+
+ +
+ + Next action time + +
+
+ +
+ 1544187775867 +
+
+
+
+
+
+
+
+ +`; + +exports[`remove lifecycle action extension should return extension when all indices have lifecycle policy 1`] = ` +Object { + "buttonLabel": "Remove lifecycle policy", + "icon": "stopFilled", + "indexNames": Array [ + Array [ + "testy3", + "testy3", + ], + ], + "renderConfirmModal": [Function], +} +`; + +exports[`retry lifecycle action extension should return extension when all indices have lifecycle errors 1`] = ` +Object { + "buttonLabel": "Retry lifecycle step", + "icon": "play", + "indexNames": Array [ + Array [ + "testy3", + "testy3", + ], + ], + "requestMethod": [Function], + "successMessage": "Called retry lifecycle step for: \\"testy3\\", \\"testy3\\"", +} +`; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap b/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap new file mode 100644 index 0000000000000..fb39b40610072 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap @@ -0,0 +1,274 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`policy table should change pages when a pagination link is clicked on 1`] = ` +Array [ + "testy0", + "testy1", + "testy10", + "testy100", + "testy101", + "testy102", + "testy103", + "testy104", + "testy11", + "testy12", +] +`; + +exports[`policy table should change pages when a pagination link is clicked on 2`] = ` +Array [ + "testy13", + "testy14", + "testy15", + "testy16", + "testy17", + "testy18", + "testy19", + "testy2", + "testy20", + "testy21", +] +`; + +exports[`policy table should filter based on content of search input 1`] = ` +Array [ + "testy0", + "testy1", + "testy10", + "testy100", + "testy101", + "testy102", + "testy103", + "testy104", + "testy11", + "testy12", +] +`; + +exports[`policy table should show empty state when there are not any policies 1`] = ` +
+
+
+
+
+
+ + + + +
+ +

+ Create your first index lifecycle policy +

+
+
+

+ An index lifecycle policy helps you manage your indices as they age. +

+
+ + +
+
+`; + +exports[`policy table should sort when covered indices header is clicked 1`] = ` +Array [ + "testy1", + "testy3", + "testy5", + "testy7", + "testy9", + "testy11", + "testy13", + "testy15", + "testy17", + "testy19", +] +`; + +exports[`policy table should sort when covered indices header is clicked 2`] = ` +Array [ + "testy104", + "testy102", + "testy100", + "testy98", + "testy96", + "testy94", + "testy92", + "testy90", + "testy88", + "testy86", +] +`; + +exports[`policy table should sort when modified date header is clicked 1`] = ` +Array [ + "testy104", + "testy103", + "testy102", + "testy101", + "testy100", + "testy99", + "testy98", + "testy97", + "testy96", + "testy95", +] +`; + +exports[`policy table should sort when modified date header is clicked 2`] = ` +Array [ + "testy0", + "testy1", + "testy2", + "testy3", + "testy4", + "testy5", + "testy6", + "testy7", + "testy8", + "testy9", +] +`; + +exports[`policy table should sort when name header is clicked 1`] = ` +Array [ + "testy99", + "testy98", + "testy97", + "testy96", + "testy95", + "testy94", + "testy93", + "testy92", + "testy91", + "testy90", +] +`; + +exports[`policy table should sort when name header is clicked 2`] = ` +Array [ + "testy0", + "testy1", + "testy10", + "testy100", + "testy101", + "testy102", + "testy103", + "testy104", + "testy11", + "testy12", +] +`; + +exports[`policy table should sort when version header is clicked 1`] = ` +Array [ + "testy0", + "testy1", + "testy2", + "testy3", + "testy4", + "testy5", + "testy6", + "testy7", + "testy8", + "testy9", +] +`; + +exports[`policy table should sort when version header is clicked 2`] = ` +Array [ + "testy104", + "testy103", + "testy102", + "testy101", + "testy100", + "testy99", + "testy98", + "testy97", + "testy96", + "testy95", +] +`; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js new file mode 100644 index 0000000000000..c399409408136 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js @@ -0,0 +1,400 @@ +/* + * 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 from 'react'; +import moment from 'moment-timezone'; +import { Provider } from 'react-redux'; +import { fetchedPolicies, fetchedNodes } from '../../public/store/actions'; +import { indexLifecycleManagementStore } from '../../public/store'; +import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; +import { EditPolicy } from '../../public/sections/edit_policy'; +// axios has a $http like interface so using it to simulate $http +import axios from 'axios'; +import { setHttpClient } from '../../public/services/api'; +setHttpClient(axios.create()); +import sinon from 'sinon'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { + positiveNumbersAboveZeroErrorMessage, + numberRequiredMessage, + positiveNumberRequiredMessage, + maximumAgeRequiredMessage, + maximumSizeRequiredMessage, + policyNameRequiredMessage, + policyNameStartsWithUnderscoreErrorMessage, + policyNameContainsCommaErrorMessage, + policyNameContainsSpaceErrorMessage, + policyNameMustBeDifferentErrorMessage, + policyNameAlreadyUsedErrorMessage, +} from '../../public/store/selectors/lifecycle'; + +let server; +let store; +const policy = { + phases: { + hot: { + min_age: '0s', + actions: { + rollover: { + max_size: '1gb', + }, + }, + }, + }, +}; +const policies = []; +for (let i = 0; i < 105; i++) { + policies.push({ + version: i, + modified_date: moment() + .subtract(i, 'days') + .valueOf(), + coveredIndices: i % 2 === 0 ? [`index${i}`] : null, + name: `testy${i}`, + policy: { + ...policy + }, + }); +} +window.scrollTo = jest.fn(); +window.TextEncoder = null; +let component; +const activatePhase = (rendered, phase) => { + const testSubject = `activatePhaseButton-${phase}`; + findTestSubject(rendered, testSubject).simulate('click'); + rendered.update(); +}; +const expectedErrorMessages = (rendered, expectedErrorMessages) => { + const errorMessages = rendered.find('.euiFormErrorText'); + expect(errorMessages.length).toBe(expectedErrorMessages.length); + expectedErrorMessages.forEach(expectedErrorMessage => { + let foundErrorMessage; + for (let i = 0; i < errorMessages.length; i++) { + if (errorMessages.at(i).text() === expectedErrorMessage) { + foundErrorMessage = true; + } + } + expect(foundErrorMessage).toBe(true); + }); +}; +const noRollover = (rendered) => { + findTestSubject(rendered, 'rolloverSwitch').simulate('change', { target: { checked: false } }); + rendered.update(); +}; +const getNodeAttributeSelect = (rendered, phase) => { + return rendered.find(`select#${phase}-selectedNodeAttrs`); +}; +const setPolicyName = (rendered, policyName) => { + const policyNameField = findTestSubject(rendered, 'policyNameField'); + policyNameField.simulate('change', { target: { value: policyName } }); + rendered.update(); +}; +const setPhaseAfter = (rendered, phase, after) => { + const afterInput = rendered.find(`input#${phase}-selectedMinimumAge`); + afterInput.simulate('change', { target: { value: after } }); + rendered.update(); +}; +const save = rendered => { + const saveButton = findTestSubject(rendered, 'savePolicyButton'); + saveButton.simulate('click'); + rendered.update(); +}; +describe('edit policy', () => { + beforeEach(() => { + store = indexLifecycleManagementStore(); + component = ( + + + + ); + store.dispatch(fetchedPolicies(policies)); + server = sinon.fakeServer.create(); + server.respondWith('/api/index_lifecycle_management/policies', [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(policies), + ]); + }); + describe('top level form', () => { + test('should show error when trying to save empty form', () => { + const rendered = mountWithIntl(component); + save(rendered); + expectedErrorMessages(rendered, [ + policyNameRequiredMessage, + maximumSizeRequiredMessage, + maximumAgeRequiredMessage, + ]); + }); + test('should show error when trying to save policy name with space', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'my policy'); + save(rendered); + expectedErrorMessages(rendered, [policyNameContainsSpaceErrorMessage]); + }); + test('should show error when trying to save policy name that is already used', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'testy0'); + rendered.update(); + save(rendered); + expectedErrorMessages(rendered, [policyNameAlreadyUsedErrorMessage]); + }); + test('should show error when trying to save as new policy but using the same name', () => { + component = ( + + + + ); + const rendered = mountWithIntl(component); + findTestSubject(rendered, 'saveAsNewSwitch').simulate('change', { target: { checked: true } }); + rendered.update(); + setPolicyName(rendered, 'testy0'); + save(rendered); + expectedErrorMessages(rendered, [policyNameMustBeDifferentErrorMessage]); + }); + test('should show error when trying to save policy name with comma', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'my,policy'); + save(rendered); + expectedErrorMessages(rendered, [policyNameContainsCommaErrorMessage]); + }); + test('should show error when trying to save policy name starting with underscore', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, '_mypolicy'); + save(rendered); + expectedErrorMessages(rendered, [policyNameStartsWithUnderscoreErrorMessage]); + }); + }); + describe('warm phase', () => { + test('should show number required error when trying to save empty warm phase', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + save(rendered); + expectedErrorMessages(rendered, [numberRequiredMessage]); + }); + test('should show positive number required above zero error when trying to save warm phase with 0 for after', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + setPhaseAfter(rendered, 'warm', 0); + save(rendered); + expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + }); + test('should show positive number required error when trying to save warm phase with -1 for after', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + setPhaseAfter(rendered, 'warm', -1); + save(rendered); + expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + }); + test('should show positive number required above zero error when trying to save warm phase with 0 for shrink', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + findTestSubject(rendered, 'shrinkSwitch').simulate('change', { target: { checked: true } }); + rendered.update(); + setPhaseAfter(rendered, 'warm', 1); + const shrinkInput = rendered.find('input#warm-selectedPrimaryShardCount'); + shrinkInput.simulate('change', { target: { value: '0' } }); + rendered.update(); + save(rendered); + expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + }); + test('should show positive number above 0 required error when trying to save warm phase with -1 for shrink', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + setPhaseAfter(rendered, 'warm', 1); + findTestSubject(rendered, 'shrinkSwitch').simulate('change', { target: { checked: true } }); + rendered.update(); + const shrinkInput = rendered.find('input#warm-selectedPrimaryShardCount'); + shrinkInput.simulate('change', { target: { value: '-1' } }); + rendered.update(); + save(rendered); + expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + }); + test('should show positive number required above zero error when trying to save warm phase with 0 for force merge', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + setPhaseAfter(rendered, 'warm', 1); + findTestSubject(rendered, 'forceMergeSwitch').simulate('change', { target: { checked: true } }); + rendered.update(); + const shrinkInput = rendered.find('input#warm-selectedForceMergeSegments'); + shrinkInput.simulate('change', { target: { value: '0' } }); + rendered.update(); + save(rendered); + expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + }); + test('should show positive number above 0 required error when trying to save warm phase with -1 for force merge', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + setPhaseAfter(rendered, 'warm', 1); + findTestSubject(rendered, 'forceMergeSwitch').simulate('change', { target: { checked: true } }); + rendered.update(); + const shrinkInput = rendered.find('input#warm-selectedForceMergeSegments'); + shrinkInput.simulate('change', { target: { value: '-1' } }); + rendered.update(); + save(rendered); + expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + }); + test('should show spinner for node attributes input when loading', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + expect(getNodeAttributeSelect(rendered, 'warm').exists()).toBeFalsy(); + }); + test('should show warning instead of node attributes input when none exist', () => { + store.dispatch(fetchedNodes({})); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeTruthy(); + expect(getNodeAttributeSelect(rendered, 'warm').exists()).toBeFalsy(); + }); + test('should show node attributes input when attributes exist', () => { + store.dispatch(fetchedNodes({ 'attribute:true': [ 'node1' ] })); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'warm'); + expect(nodeAttributesSelect.exists()).toBeTruthy(); + expect(nodeAttributesSelect.find('option').length).toBe(2); + }); + test('should show view node attributes link when attribute selected and show flyout when clicked', () => { + store.dispatch(fetchedNodes({ 'attribute:true': [ 'node1' ] })); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'warm'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'warm'); + expect(nodeAttributesSelect.exists()).toBeTruthy(); + expect(findTestSubject(rendered, 'warm-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); + expect(nodeAttributesSelect.find('option').length).toBe(2); + nodeAttributesSelect.simulate('change', { target: { value: 'attribute:true' } }); + rendered.update(); + const flyoutButton = findTestSubject(rendered, 'warm-viewNodeDetailsFlyoutButton'); + expect(flyoutButton.exists()).toBeTruthy(); + flyoutButton.simulate('click'); + rendered.update(); + expect(rendered.find('.euiFlyout').exists()).toBeTruthy(); + }); + }); + describe('cold phase', () => { + test('should show positive number required error when trying to save cold phase with 0 for after', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'cold'); + setPhaseAfter(rendered, 'cold', 0); + save(rendered); + expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + }); + test('should show positive number required error when trying to save cold phase with -1 for after', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'cold'); + setPhaseAfter(rendered, 'cold', -1); + save(rendered); + expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + }); + test('should show spinner for node attributes input when loading', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'cold'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + expect(getNodeAttributeSelect(rendered, 'cold').exists()).toBeFalsy(); + }); + test('should show warning instead of node attributes input when none exist', () => { + store.dispatch(fetchedNodes({})); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'cold'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeTruthy(); + expect(getNodeAttributeSelect(rendered, 'cold').exists()).toBeFalsy(); + }); + test('should show node attributes input when attributes exist', () => { + store.dispatch(fetchedNodes({ 'attribute:true': [ 'node1' ] })); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'cold'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'cold'); + expect(nodeAttributesSelect.exists()).toBeTruthy(); + expect(nodeAttributesSelect.find('option').length).toBe(2); + }); + test('should show view node attributes link when attribute selected and show flyout when clicked', () => { + store.dispatch(fetchedNodes({ 'attribute:true': [ 'node1' ] })); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'cold'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'cold'); + expect(nodeAttributesSelect.exists()).toBeTruthy(); + expect(findTestSubject(rendered, 'cold-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); + expect(nodeAttributesSelect.find('option').length).toBe(2); + nodeAttributesSelect.simulate('change', { target: { value: 'attribute:true' } }); + rendered.update(); + const flyoutButton = findTestSubject(rendered, 'cold-viewNodeDetailsFlyoutButton'); + expect(flyoutButton.exists()).toBeTruthy(); + flyoutButton.simulate('click'); + rendered.update(); + expect(rendered.find('.euiFlyout').exists()).toBeTruthy(); + }); + }); + describe('delete phase', () => { + test('should show positive number required error when trying to save delete phase with 0 for after', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'delete'); + setPhaseAfter(rendered, 'delete', 0); + save(rendered); + expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + }); + test('should show positive number required error when trying to save delete phase with -1 for after', () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + activatePhase(rendered, 'delete'); + setPhaseAfter(rendered, 'delete', -1); + save(rendered); + expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + }); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js new file mode 100644 index 0000000000000..bec2a3cf16af4 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js @@ -0,0 +1,172 @@ +/* + * 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 moment from 'moment-timezone'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { fetchedPolicies } from '../../public/store/actions'; +import { indexLifecycleManagementStore } from '../../public/store'; +import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; +import { PolicyTable } from '../../public/sections/policy_table'; +import { findTestSubject, takeMountedSnapshot } from '@elastic/eui/lib/test'; +// axios has a $http like interface so using it to simulate $http +import axios from 'axios'; +import { setHttpClient } from '../../public/services/api'; +setHttpClient(axios.create()); +import sinon from 'sinon'; +let server = null; + +let store = null; +const policies = []; +for (let i = 0; i < 105; i++) { + policies.push({ + version: i, + modified_date: moment().subtract(i, 'days').valueOf(), + coveredIndices: i % 2 === 0 ? [`index${i}`] : null, + name: `testy${i}` + }); +} +jest.mock(''); +let component = null; + +const snapshot = rendered => { + expect(rendered).toMatchSnapshot(); +}; +const mountedSnapshot = rendered => { + expect(takeMountedSnapshot(rendered)).toMatchSnapshot(); +}; +const names = rendered => { + return findTestSubject(rendered, 'policyTablePolicyNameLink'); +}; +const namesText = rendered => { + return names(rendered).map(button => button.text()); +}; + +const testSort = (headerName) => { + const rendered = mountWithIntl(component); + const nameHeader = findTestSubject( + rendered, + `policyTableHeaderCell-${headerName}` + ).find('button'); + nameHeader.simulate('click'); + rendered.update(); + snapshot(namesText(rendered)); + nameHeader.simulate('click'); + rendered.update(); + snapshot(namesText(rendered)); +}; +const openContextMenu = (buttonIndex) => { + const rendered = mountWithIntl(component); + const actionsButton = findTestSubject( + rendered, + 'policyActionsContextMenuButton' + ); + actionsButton.at(buttonIndex).simulate('click'); + rendered.update(); + return rendered; +}; + +describe('policy table', () => { + beforeEach(() => { + store = indexLifecycleManagementStore(); + component = ( + + + + ); + store.dispatch(fetchedPolicies(policies)); + server = sinon.fakeServer.create(); + server.respondWith('/api/index_lifecycle_management/policies', [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(policies) + ]); + }); + test('should show spinner when policies are loading', () => { + store = indexLifecycleManagementStore(); + component = ( + + + + ); + const rendered = mountWithIntl(component); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); + + }); + test('should show empty state when there are not any policies', () => { + store.dispatch(fetchedPolicies([])); + const rendered = mountWithIntl(component); + mountedSnapshot(rendered); + }); + test('should change pages when a pagination link is clicked on', () => { + const rendered = mountWithIntl(component); + snapshot(namesText(rendered)); + const pagingButtons = rendered.find('.euiPaginationButton'); + pagingButtons.at(2).simulate('click'); + rendered.update(); + snapshot(namesText(rendered)); + }); + test('should show more when per page value is increased', () => { + const rendered = mountWithIntl(component); + const perPageButton = rendered.find('span[children="Rows per page: 10"]'); + perPageButton.simulate('click'); + rendered.update(); + const fiftyButton = rendered.find('span[children="50 rows"]'); + fiftyButton.simulate('click'); + rendered.update(); + expect(namesText(rendered).length).toBe(50); + }); + test('should filter based on content of search input', () => { + const rendered = mountWithIntl(component); + const searchInput = rendered.find('.euiFieldSearch').first(); + searchInput.instance().value = 'testy0'; + searchInput.simulate('keyup', { key: 'Enter', keyCode: 13, which: 13 }); + rendered.update(); + snapshot(namesText(rendered)); + }); + test('should sort when name header is clicked', () => { + testSort('name'); + }); + test('should sort when version header is clicked', () => { + testSort('version'); + }); + test('should sort when modified date header is clicked', () => { + testSort('modified_date'); + }); + test('should sort when covered indices header is clicked', () => { + testSort('coveredIndices'); + }); + test('should have proper actions in context menu when there are covered indices', () => { + const rendered = openContextMenu(0); + const buttons = rendered.find('button.euiContextMenuItem'); + expect(buttons.length).toBe(3); + expect(buttons.at(0).text()).toBe('View indices linked to policy'); + expect(buttons.at(1).text()).toBe('Add policy to index template'); + expect(buttons.at(2).text()).toBe('Delete policy'); + expect(buttons.at(2).getDOMNode().disabled).toBeTruthy(); + }); + test('should have proper actions in context menu when there are not covered indices', () => { + const rendered = openContextMenu(1); + const buttons = rendered.find('button.euiContextMenuItem'); + expect(buttons.length).toBe(2); + expect(buttons.at(0).text()).toBe('Add policy to index template'); + expect(buttons.at(1).text()).toBe('Delete policy'); + expect(buttons.at(1).getDOMNode().disabled).toBeFalsy(); + }); + test('confirmation modal should show when delete button is pressed', () => { + const rendered = openContextMenu(1); + const deleteButton = rendered.find('button.euiContextMenuItem').at(1); + deleteButton.simulate('click'); + rendered.update(); + expect(rendered.find('.euiModal--confirmation').exists()).toBeTruthy(); + }); + test('confirmation modal should show when add policy to index template button is pressed', () => { + const rendered = openContextMenu(1); + const deleteButton = rendered.find('button.euiContextMenuItem').at(0); + deleteButton.simulate('click'); + rendered.update(); + expect(rendered.find('.euiModal--confirmation').exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js new file mode 100644 index 0000000000000..607bd96694cac --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js @@ -0,0 +1,224 @@ +/* + * 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 { mountWithIntl } from '../../../test_utils/enzyme_helpers'; +import { + retryLifecycleActionExtension, + removeLifecyclePolicyActionExtension, + addLifecyclePolicyActionExtension, + ilmBannerExtension, + ilmFilterExtension, + ilmSummaryExtension, +} from '../public/extend_index_management'; +const indexWithoutLifecyclePolicy = { + health: 'yellow', + status: 'open', + name: 'noPolicy', + uuid: 'BJ-nrZYuSrG-jaofr6SeLg', + primary: '1', + replica: '1', + documents: '1', + documents_deleted: '0', + size: '3.4kb', + primary_size: '3.4kb', + aliases: 'none', + ilm: { + index: 'testy1', + managed: false, + }, +}; +const indexWithLifecyclePolicy = { + health: 'yellow', + status: 'open', + name: 'testy3', + uuid: 'XL11TLa3Tvq298_dMUzLHQ', + primary: '1', + replica: '1', + documents: '2', + documents_deleted: '0', + size: '6.5kb', + primary_size: '6.5kb', + aliases: 'none', + ilm: { + index: 'testy3', + managed: true, + policy: 'testy', + lifecycle_date_millis: 1544020872361, + phase: 'new', + phase_time_millis: 1544187775867, + action: 'complete', + action_time_millis: 1544187775867, + step: 'complete', + step_time_millis: 1544187775867, + }, +}; +const indexWithLifecycleError = { + health: 'yellow', + status: 'open', + name: 'testy3', + uuid: 'XL11TLa3Tvq298_dMUzLHQ', + primary: '1', + replica: '1', + documents: '2', + documents_deleted: '0', + size: '6.5kb', + primary_size: '6.5kb', + aliases: 'none', + ilm: { + index: 'testy3', + managed: true, + policy: 'testy', + lifecycle_date_millis: 1544020872361, + phase: 'hot', + phase_time_millis: 1544187775891, + action: 'rollover', + action_time_millis: 1544187775891, + step: 'ERROR', + step_time_millis: 1544187776208, + failed_step: 'check-rollover-ready', + step_info: { + type: 'illegal_argument_exception', + reason: 'setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined', + }, + phase_execution: { + policy: 'testy', + phase_definition: { min_age: '0s', actions: { rollover: { max_size: '1gb' } } }, + version: 1, + modified_date_in_millis: 1544031699844, + }, + }, +}; +describe('retry lifecycle action extension', () => { + test('should return null when no indices have index lifecycle policy', () => { + const extension = retryLifecycleActionExtension([indexWithoutLifecyclePolicy]); + expect(extension).toBeNull(); + }); + test('should return null when no index has lifecycle errors', () => { + const extension = retryLifecycleActionExtension([ + indexWithLifecyclePolicy, + indexWithLifecyclePolicy, + ]); + expect(extension).toBeNull(); + }); + test('should return null when not all indices have lifecycle errors', () => { + const extension = retryLifecycleActionExtension([ + indexWithLifecyclePolicy, + indexWithLifecycleError, + ]); + expect(extension).toBeNull(); + }); + test('should return extension when all indices have lifecycle errors', () => { + const extension = retryLifecycleActionExtension([ + indexWithLifecycleError, + indexWithLifecycleError, + ]); + expect(extension).toBeDefined(); + expect(extension).toMatchSnapshot(); + }); +}); +describe('remove lifecycle action extension', () => { + test('should return null when no indices have index lifecycle policy', () => { + const extension = removeLifecyclePolicyActionExtension([indexWithoutLifecyclePolicy]); + expect(extension).toBeNull(); + }); + test('should return null when some indices have index lifecycle policy', () => { + const extension = removeLifecyclePolicyActionExtension([ + indexWithoutLifecyclePolicy, + indexWithLifecyclePolicy + ]); + expect(extension).toBeNull(); + }); + test('should return extension when all indices have lifecycle policy', () => { + const extension = removeLifecyclePolicyActionExtension([ + indexWithLifecycleError, + indexWithLifecycleError, + ]); + expect(extension).toBeDefined(); + expect(extension).toMatchSnapshot(); + }); +}); +describe('add lifecycle policy action extension', () => { + test('should return null when index has index lifecycle policy', () => { + const extension = addLifecyclePolicyActionExtension([indexWithLifecyclePolicy]); + expect(extension).toBeNull(); + }); + test('should return null when more than one index is passed', () => { + const extension = addLifecyclePolicyActionExtension([ + indexWithoutLifecyclePolicy, + indexWithoutLifecyclePolicy, + ]); + expect(extension).toBeNull(); + }); + test('should return extension when one index is passed and it does not have lifecycle policy', () => { + const extension = addLifecyclePolicyActionExtension([indexWithoutLifecyclePolicy]); + expect(extension.renderConfirmModal).toBeDefined; + const component = extension.renderConfirmModal(jest.fn()); + const rendered = mountWithIntl(component); + expect(rendered.exists('.euiModal--confirmation')); + }); +}); +describe('ilm banner extension', () => { + test('should return null when no index has index lifecycle policy', () => { + const extension = ilmBannerExtension([ + indexWithoutLifecyclePolicy, + indexWithoutLifecyclePolicy, + ]); + expect(extension).toBeNull(); + }); + test('should return null no index has lifecycle error', () => { + const extension = ilmBannerExtension([ + indexWithoutLifecyclePolicy, + indexWithLifecyclePolicy, + ]); + expect(extension).toBeNull(); + }); + test('should return extension when any index has lifecycle error', () => { + const extension = ilmBannerExtension([ + indexWithoutLifecyclePolicy, + indexWithLifecyclePolicy, + indexWithLifecycleError, + ]); + expect(extension).toBeDefined(); + expect(extension).toMatchSnapshot(); + }); +}); +describe('ilm summary extension', () => { + test('should render null when index has no index lifecycle policy', () => { + const extension = ilmSummaryExtension(indexWithoutLifecyclePolicy); + const rendered = mountWithIntl(extension); + expect(rendered.html()).toBeNull(); + }); + test('should return extension when index has lifecycle policy', () => { + const extension = ilmSummaryExtension(indexWithLifecyclePolicy); + expect(extension).toBeDefined(); + const rendered = mountWithIntl(extension); + expect(rendered).toMatchSnapshot(); + }); + test('should return extension when index has lifecycle error', () => { + const extension = ilmSummaryExtension(indexWithLifecycleError); + expect(extension).toBeDefined(); + const rendered = mountWithIntl(extension); + expect(rendered).toMatchSnapshot(); + }); +}); +describe('ilm filter extension', () => { + test('should return empty array when no indices have index lifecycle policy', () => { + const extension = ilmFilterExtension([ + indexWithoutLifecyclePolicy, + indexWithoutLifecyclePolicy, + ]); + expect(extension.length).toBe(0); + }); + test('should return extension when any index has lifecycle policy', () => { + const extension = ilmFilterExtension([ + indexWithLifecyclePolicy, + indexWithoutLifecyclePolicy, + indexWithoutLifecyclePolicy, + ]); + expect(extension).toBeDefined(); + expect(extension).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/common/constants/index.js b/x-pack/plugins/index_lifecycle_management/common/constants/index.js new file mode 100644 index 0000000000000..5525b2b86f4d1 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/common/constants/index.js @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export const BASE_PATH = '/management/elasticsearch/index_lifecycle_management/'; +export const PLUGIN_ID = 'index_lifecycle_management'; diff --git a/x-pack/plugins/index_lifecycle_management/index.js b/x-pack/plugins/index_lifecycle_management/index.js new file mode 100644 index 0000000000000..cac3aa371411f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/index.js @@ -0,0 +1,63 @@ +/* + * 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 { resolve } from 'path'; +import { registerTemplatesRoutes } from './server/routes/api/templates'; +import { registerNodesRoutes } from './server/routes/api/nodes'; +import { registerPoliciesRoutes } from './server/routes/api/policies'; +import { registerLifecycleRoutes } from './server/routes/api/lifecycle'; +import { registerIndexRoutes } from './server/routes/api/index'; +import { registerLicenseChecker } from './server/lib/register_license_checker'; +import { PLUGIN_ID } from './common/constants'; +import { indexLifecycleDataEnricher } from './index_lifecycle_data'; +export function indexLifecycleManagement(kibana) { + return new kibana.Plugin({ + config: (Joi) => { + return Joi.object({ + enabled: Joi.boolean().default(true), + ui: Joi.object({ + enabled: Joi.boolean().default(true), + }).default(), + filteredNodeAttributes: Joi.array().items(Joi.string()).default([]) + }).default(); + }, + id: PLUGIN_ID, + publicDir: resolve(__dirname, 'public'), + configPrefix: 'xpack.ilm', + require: ['kibana', 'elasticsearch', 'xpack_main', 'index_management'], + uiExports: { + styleSheetPaths: `${__dirname}/public/index.scss`, + managementSections: ['plugins/index_lifecycle_management'], + injectDefaultVars(server) { + const config = server.config(); + return { + ilmUiEnabled: config.get('xpack.ilm.ui.enabled') + }; + }, + }, + isEnabled(config) { + return ( + config.get('xpack.ilm.enabled') && + config.has('index_management.enabled') && + config.get('index_management.enabled') + ); + }, + init: function (server) { + registerLicenseChecker(server); + registerTemplatesRoutes(server); + registerNodesRoutes(server); + registerPoliciesRoutes(server); + registerLifecycleRoutes(server); + registerIndexRoutes(server); + if ( + server.plugins.index_management && + server.plugins.index_management.addIndexManagementDataEnricher + ) { + server.plugins.index_management.addIndexManagementDataEnricher(indexLifecycleDataEnricher); + } + }, + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/index_lifecycle_data.js b/x-pack/plugins/index_lifecycle_management/index_lifecycle_data.js new file mode 100644 index 0000000000000..57b90204ce224 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/index_lifecycle_data.js @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export const indexLifecycleDataEnricher = async (indicesList, callWithRequest) => { + if (!indicesList || !indicesList.length) { + return; + } + const params = { + path: '/*/_ilm/explain', + method: 'GET', + }; + const { indices: ilmIndicesData } = await callWithRequest('transport.request', params); + return indicesList.map(index => { + return { + ...index, + ilm: { ...(ilmIndicesData[index.name] || {}) }, + }; + }); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/_index_lifecycle_management.scss b/x-pack/plugins/index_lifecycle_management/public/_index_lifecycle_management.scss new file mode 100644 index 0000000000000..341ed8a576d4a --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/_index_lifecycle_management.scss @@ -0,0 +1,12 @@ +.policyTable__horizontalScrollContainer { + overflow-x: auto; + max-width: 100%; + height: 100vh; +} +.policyTable__horizontalScroll { + min-width: 800px; + width: 100%; +} +.ilmEditPolicyPageContent { + max-width: 1200px !important; +} \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/app.js b/x-pack/plugins/index_lifecycle_management/public/app.js new file mode 100644 index 0000000000000..e316d11017a14 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/app.js @@ -0,0 +1,21 @@ +/* + * 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 from 'react'; +import { HashRouter, Switch, Route, Redirect } from 'react-router-dom'; +import { EditPolicy } from './sections/edit_policy'; +import { PolicyTable } from './sections/policy_table'; +import { BASE_PATH } from '../common/constants'; + +export const App = () => ( + + + + + + + +); diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.js new file mode 100644 index 0000000000000..5cec2ac4dd73e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.js @@ -0,0 +1,281 @@ +/* + * 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, { Component } from 'react'; +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + EuiLink, + EuiSelect, + EuiForm, + EuiFormRow, + EuiOverlayMask, + EuiConfirmModal, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiCallOut, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { BASE_PATH } from '../../../common/constants'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { toastNotifications } from 'ui/notify'; +import { loadPolicies, addLifecyclePolicyToIndex } from '../../services/api'; +import { showApiError } from '../../services/api_errors'; +export class AddLifecyclePolicyConfirmModal extends Component { + constructor(props) { + super(props); + this.state = { + policies: [], + selectedPolicyName: '', + selectedAlias: '', + }; + } + addPolicy = async () => { + const { indexName, httpClient, closeModal, reloadIndices } = this.props; + const { selectedPolicyName, selectedAlias } = this.state; + if (!selectedPolicyName) { + this.setState({ policyError: i18n.translate( + 'xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.noPolicySelectedErrorMessage', + { defaultMessage: 'You must select a policy.' }) }); + return; + } + try { + const body = { + indexName, + policyName: selectedPolicyName, + alias: selectedAlias, + }; + await addLifecyclePolicyToIndex(body, httpClient); + closeModal(); + toastNotifications.addSuccess( + i18n.translate( + 'xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.addPolicyToIndexSuccess', + { + defaultMessage: 'Added policy {policyName} to index {indexName}.', + values: { + policyName: selectedPolicyName, + indexName, + }, + } + ) + ); + reloadIndices(); + } catch (err) { + showApiError( + err, + i18n.translate( + 'xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.addPolicyToIndexError', + { + defaultMessage: 'Error adding policy to index', + } + ) + ); + } + }; + renderAliasFormElement = selectedPolicy => { + const { selectedAlias } = this.state; + const { index } = this.props; + const showAliasSelect = + selectedPolicy && get(selectedPolicy, 'policy.phases.hot.actions.rollover'); + if (!showAliasSelect) { + return null; + } + const { aliases } = index; + if (aliases === 'none') { + return ( + + } + color="warning" + > + + + ); + } + const aliasOptions = aliases.map(alias => { + return { + text: alias, + value: alias, + }; + }); + aliasOptions.unshift({ + text: i18n.translate( + 'xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.chooseAliasMessage', + { + defaultMessage: 'Choose an alias', + } + ), + value: '', + }); + return ( + + } + > + { + this.setState({ selectedAlias: e.target.value }); + }} + /> + + ); + }; + renderForm() { + const { policies, selectedPolicyName, policyError } = this.state; + const selectedPolicy = selectedPolicyName + ? policies.find(policy => policy.name === selectedPolicyName) + : null; + + const options = policies.map(({ name }) => { + return { + value: name, + text: name, + }; + }); + options.unshift({ + value: '', + text: i18n.translate( + 'xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.choosePolicyMessage', + { + defaultMessage: 'Select a lifecycle policy', + } + ), + }); + return ( + + + } + > + { + this.setState({ policyError: null, selectedPolicyName: e.target.value }); + }} + /> + + {this.renderAliasFormElement(selectedPolicy)} + + ); + } + async componentDidMount() { + try { + const policies = await loadPolicies(false, this.props.httpClient); + this.setState({ policies }); + } catch (err) { + showApiError( + err, + i18n.translate( + 'xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.loadPolicyError', + { + defaultMessage: 'Error loading policy list', + } + ) + ); + this.props.closeModal(); + } + } + render() { + const { policies } = this.state; + const { indexName, closeModal } = this.props; + const title = ( + + ); + if (!policies.length) { + return ( + + + + + {title} + + + + + + } + color="warning" + > +

+ + + +

+
+
+
+
+ ); + } + return ( + + + } + confirmButtonText={ + + } + > + {this.renderForm()} + + + ); + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.js new file mode 100644 index 0000000000000..697f94ecdec09 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.js @@ -0,0 +1,207 @@ +/* + * 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, { Component, Fragment } from 'react'; +import moment from 'moment-timezone'; +import { + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiSpacer, + EuiTitle, + EuiLink, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { getPolicyPath } from '../../services/navigation'; +const getHeaders = () => { + return { + policy: i18n.translate('xpack.idxMgmt.indexLifecycleMgmtSummary.headers.lifecyclePolicyHeader', { + defaultMessage: 'Lifecycle policy', + }), + phase: i18n.translate('xpack.idxMgmt.indexLifecycleMgmtSummary.headers.currentPhaseHeader', { + defaultMessage: 'Current phase', + }), + action: i18n.translate('xpack.idxMgmt.indexLifecycleMgmtSummary.headers.nextActionHeader', { + defaultMessage: 'Next action', + }), + action_time_millis: i18n.translate('xpack.idxMgmt.indexLifecycleMgmtSummary.headers.nextActionTimeHeader', { + defaultMessage: 'Next action time', + }), + failed_step: i18n.translate('xpack.idxMgmt.indexLifecycleMgmtSummary.headers.failedStepHeader', { + defaultMessage: 'Failed step', + }), + }; +}; +export class IndexLifecycleSummary extends Component { + constructor(props) { + super(props); + this.state = { + showStackPopover: false, + showPhaseExecutionPopover: false, + }; + } + toggleStackPopover = () => { + this.setState({ showStackPopover: !this.state.showStackPopover }); + } + closeStackPopover = () => { + this.setState({ showStackPopover: false }); + } + togglePhaseExecutionPopover = () => { + this.setState({ showPhaseExecutionPopover: !this.state.showPhaseExecutionPopover }); + } + closePhaseExecutionPopover = () => { + this.setState({ showPhaseExecutionPopover: false }); + } + renderStackPopoverButton(ilm) { + const button = ( + + + + ); + return ( + +
+
{ilm.step_info.stack_trace}
+
+
+ ); + } + renderPhaseExecutionPopoverButton(ilm) { + if (!ilm.phase_execution) { + return null; + } + const button = ( + + + + ); + return ( + + + + +
+
{JSON.stringify(ilm.phase_execution, null, 2)}
+
+
+ ); + } + buildRows() { + const { index: { ilm = {} } } = this.props; + const headers = getHeaders(); + const rows = { + left: [], + right: [] + }; + Object.keys(headers).forEach((fieldName, arrayIndex) => { + const value = ilm[fieldName]; + let content; + if (fieldName === 'action_time') { + content = moment(value).format('YYYY-MM-DD HH:mm:ss'); + } else if (fieldName === 'policy') { + content = ({value}); + } else { + content = value; + } + content = content || '-'; + const cell = [ + + {headers[fieldName]} + , + + {content} + + ]; + if (arrayIndex % 2 === 0) { + rows.left.push(cell); + } else { + rows.right.push(cell); + } + }); + rows.right.push(this.renderPhaseExecutionPopoverButton(ilm)); + return rows; + } + + render() { + const { index: { ilm = {} } } = this.props; + if (!ilm.managed) { + return null; + } + const { left, right } = this.buildRows(); + return ( + + +

+ +

+
+ { ilm.step_info && ilm.step_info.type ? ( + + + + } + iconType="cross" + > + {ilm.step_info.type}: {ilm.step_info.reason} + + {this.renderStackPopoverButton(ilm)} + + + ) : null} + + + + + {left} + + + + + {right} + + + +
+ ); + } +} \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.js new file mode 100644 index 0000000000000..42795a03a7704 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.js @@ -0,0 +1,121 @@ +/* + * 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, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiOverlayMask, + EuiConfirmModal, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { toastNotifications } from 'ui/notify'; +import { removeLifecycleForIndex } from '../../services/api'; +import { showApiError } from '../../services/api_errors'; +export class RemoveLifecyclePolicyConfirmModal extends Component { + constructor(props) { + super(props); + this.state = { + policies: [], + selectedPolicyName: null, + selectedAlias: null, + }; + } + oneIndexSelected = () => { + return this.props.indexNames.length === 1; + }; + getEntity = oneIndexSelected => { + return oneIndexSelected ? ( + + ) : ( + + ); + }; + removePolicy = async () => { + const { indexNames, httpClient, closeModal, reloadIndices } = this.props; + const target = this. getTarget(); + try { + await removeLifecycleForIndex(indexNames, httpClient); + closeModal(); + toastNotifications.addSuccess( + i18n.translate( + 'xpack.indexLifecycleMgmt.indexManagementTable.removeLifecyclePolicyConfirmModal.removePolicySuccess', + { + defaultMessage: 'Removed lifecycle policy from {target}', + values: { + target + }, + } + ) + ); + reloadIndices(); + } catch (err) { + showApiError( + err, + i18n.translate( + 'xpack.indexLifecycleMgmt.indexManagementTable.removeLifecyclePolicyConfirmModal.removePolicyToIndexError', + { + defaultMessage: 'Error removing policy', + } + ) + ); + } + }; + getTarget() { + const { indexNames } = this.props; + const oneIndexSelected = this.oneIndexSelected(); + const entity = this.getEntity(oneIndexSelected); + return oneIndexSelected ? (indexNames[0]) : `${indexNames.length} ${entity}`; + } + render() { + + const { closeModal } = this.props; + const target = this. getTarget(); + return ( + + + } + onCancel={closeModal} + onConfirm={this.removePolicy} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + + + + ); + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js new file mode 100644 index 0000000000000..2e084dd08ac10 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js @@ -0,0 +1,209 @@ +/* + * 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 from 'react'; +import { IndexLifecycleSummary } from './components/index_lifecycle_summary'; +import { AddLifecyclePolicyConfirmModal } from './components/add_lifecycle_confirm_modal'; +import { RemoveLifecyclePolicyConfirmModal } from './components/remove_lifecycle_confirm_modal'; +import { get, every, any } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + addSummaryExtension, + addBannerExtension, + addActionExtension, + addFilterExtension, +} from '../../../index_management/public/index_management_extensions'; +import { retryLifecycleForIndex } from '../services/api'; +import { EuiSearchBar } from '@elastic/eui'; + +const stepPath = 'ilm.step'; +export const retryLifecycleActionExtension = indices => { + const allHaveErrors = every(indices, index => { + return index.ilm && index.ilm.failed_step; + }); + if (!allHaveErrors) { + return null; + } + const indexNames = indices.map(({ name }) => name); + return { + requestMethod: retryLifecycleForIndex, + icon: 'play', + indexNames: [indexNames], + buttonLabel: i18n.translate('xpack.idxMgmt.retryIndexLifecycleActionButtonLabel', { + defaultMessage: 'Retry lifecycle step', + }), + successMessage: i18n.translate( + 'xpack.idxMgmt.retryIndexLifecycleAction.retriedLifecycleMessage', + { + defaultMessage: 'Called retry lifecycle step for: {indexNames}', + values: { indexNames: indexNames.map(indexName => `"${indexName}"`).join(', ') }, + } + ), + }; +}; + +export const removeLifecyclePolicyActionExtension = (indices, reloadIndices) => { + const allHaveIlm = every(indices, index => { + return index.ilm && index.ilm.managed; + }); + if (!allHaveIlm) { + return null; + } + const indexNames = indices.map(({ name }) => name); + return { + renderConfirmModal: (closeModal, httpClient) => { + return ( + + ); + }, + icon: 'stopFilled', + indexNames: [indexNames], + buttonLabel: i18n.translate('xpack.idxMgmt.removeIndexLifecycleActionButtonLabel', { + defaultMessage: 'Remove lifecycle policy', + }), + }; +}; + +export const addLifecyclePolicyActionExtension = (indices, reloadIndices) => { + if (indices.length !== 1) { + return null; + } + const index = indices[0]; + const hasIlm = index.ilm && index.ilm.managed; + + if (hasIlm) { + return null; + } + const indexName = index.name; + return { + renderConfirmModal: (closeModal, httpClient) => { + return ( + + ); + }, + icon: 'plusInCircle', + buttonLabel: i18n.translate('xpack.idxMgmt.addLifecyclePolicyActionButtonLabel', { + defaultMessage: 'Add lifecycle policy', + }), + }; +}; + +addActionExtension(retryLifecycleActionExtension); +addActionExtension(removeLifecyclePolicyActionExtension); +addActionExtension(addLifecyclePolicyActionExtension); + +export const ilmBannerExtension = indices => { + const { Query } = EuiSearchBar; + if (!indices.length) { + return null; + } + const indicesWithLifecycleErrors = indices.filter(index => { + return get(index, stepPath) === 'ERROR'; + }); + const numIndicesWithLifecycleErrors = indicesWithLifecycleErrors.length; + if (!numIndicesWithLifecycleErrors) { + return null; + } + return { + type: 'warning', + filter: Query.parse(`${stepPath}:ERROR`), + filterLabel: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtBanner.filterLabel', { + defaultMessage: 'Show errors', + }), + title: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtBanner.errorMessage', { + defaultMessage: `{ numIndicesWithLifecycleErrors, number} + {numIndicesWithLifecycleErrors, plural, one {index has} other {indices have} } + lifecycle errors`, + values: { numIndicesWithLifecycleErrors }, + }), + }; +}; + +addBannerExtension(ilmBannerExtension); + +export const ilmSummaryExtension = index => { + return ; +}; + +addSummaryExtension(ilmSummaryExtension); + +export const ilmFilterExtension = indices => { + const hasIlm = any(indices, index => index.ilm && index.ilm.managed); + if (!hasIlm) { + return []; + } else { + return [ + { + type: 'field_value_selection', + name: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.lifecycleStatusLabel', { + defaultMessage: 'Lifecycle status', + }), + multiSelect: false, + field: 'ilm.managed', + options: [ + { + value: true, + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.managedLabel', { + defaultMessage: 'Managed', + }), + }, + { + value: false, + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.unmanagedLabel', { + defaultMessage: 'Unmanaged', + }), + }, + ], + }, + { + type: 'field_value_selection', + field: 'ilm.phase', + name: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.lifecyclePhaseLabel', { + defaultMessage: 'Lifecycle phase', + }), + multiSelect: 'or', + options: [ + { + value: 'hot', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.hotLabel', { + defaultMessage: 'Hot', + }), + }, + { + value: 'warm', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.warmLabel', { + defaultMessage: 'Warm', + }), + }, + { + value: 'cold', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.coldLabel', { + defaultMessage: 'Cold', + }), + }, + { + value: 'delete', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.deleteLabel', { + defaultMessage: 'Delete', + }), + }, + ], + }, + ]; + } +}; + +addFilterExtension(ilmFilterExtension); diff --git a/x-pack/plugins/index_lifecycle_management/public/index.js b/x-pack/plugins/index_lifecycle_management/public/index.js new file mode 100644 index 0000000000000..58ba3054db009 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/index.js @@ -0,0 +1,9 @@ +/* + * 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 './register_management_section'; +import './register_routes'; +import './extend_index_management'; diff --git a/x-pack/plugins/index_lifecycle_management/public/index.scss b/x-pack/plugins/index_lifecycle_management/public/index.scss new file mode 100644 index 0000000000000..f97f8a43731cb --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/index.scss @@ -0,0 +1,3 @@ +// Import the EUI global scope so we can use EUI constants +@import 'ui/public/styles/_styling_constants'; +@import 'index_lifecycle_management'; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/main.html b/x-pack/plugins/index_lifecycle_management/public/main.html new file mode 100644 index 0000000000000..d54cba0632cdf --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/main.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/plugins/index_lifecycle_management/public/register_management_section.js b/x-pack/plugins/index_lifecycle_management/public/register_management_section.js new file mode 100644 index 0000000000000..5dd3ea151e7be --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/register_management_section.js @@ -0,0 +1,21 @@ +/* + * 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 chrome from 'ui/chrome'; +import { management } from 'ui/management'; +import { i18n } from '@kbn/i18n'; +import { BASE_PATH } from '../common/constants'; +const esSection = management.getSection('elasticsearch'); +if (chrome.getInjected('ilmUiEnabled')) { + esSection.register('index_lifecycle_policies', { + visible: true, + display: i18n.translate('xpack.indexLifecycleMgmt.appTitle', { + defaultMessage: 'Index Lifecycle Policies', + }), + order: 2, + url: `#${BASE_PATH}policies`, + }); +} + diff --git a/x-pack/plugins/index_lifecycle_management/public/register_routes.js b/x-pack/plugins/index_lifecycle_management/public/register_routes.js new file mode 100644 index 0000000000000..643ac68af6131 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/register_routes.js @@ -0,0 +1,59 @@ +/* + * 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 from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Provider } from 'react-redux'; +import { setHttpClient } from './services/api'; +import chrome from 'ui/chrome'; +import { App } from './app'; +import { BASE_PATH } from '../common/constants'; +import { indexLifecycleManagementStore } from './store'; +import { I18nProvider } from '@kbn/i18n/react'; +import { setUrlService } from './services/navigation'; + +import routes from 'ui/routes'; + +import template from './main.html'; +import { manageAngularLifecycle } from './services/manage_angular_lifecycle'; +let elem; +const renderReact = async (elem) => { + render( + + + + + , + elem + ); +}; +if (chrome.getInjected('ilmUiEnabled')) { + routes.when(`${BASE_PATH}:view?/:action?/:id?`, { + template: template, + controllerAs: 'indexLifecycleManagement', + controller: class IndexLifecycleManagementController { + constructor($scope, $route, $http, kbnUrl, $rootScope) { + // clean up previously rendered React app if one exists + // this happens because of React Router redirects + elem && unmountComponentAtNode(elem); + setHttpClient($http); + setUrlService({ + change(url) { + kbnUrl.change(url); + $rootScope.$digest(); + } + }); + $scope.$$postDigest(() => { + elem = document.getElementById('indexLifecycleManagementReactRoot'); + renderReact(elem); + manageAngularLifecycle($scope, $route, elem); + }); + } + } + }); +} + + diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/components/active_badge.js b/x-pack/plugins/index_lifecycle_management/public/sections/components/active_badge.js new file mode 100644 index 0000000000000..4a270c9e002f0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/components/active_badge.js @@ -0,0 +1,18 @@ +/* + * 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 from 'react'; +import { EuiBadge } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +export const ActiveBadge = () => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/components/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/components/index.js new file mode 100644 index 0000000000000..e87d7b34b8f68 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/components/index.js @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export { ActiveBadge } from './active_badge'; +export { LearnMoreLink } from './learn_more_link'; +export { PhaseErrorMessage } from './phase_error_message'; +export { OptionalLabel } from './optional_label'; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/components/learn_more_link.js b/x-pack/plugins/index_lifecycle_management/public/sections/components/learn_more_link.js new file mode 100644 index 0000000000000..38192f6391072 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/components/learn_more_link.js @@ -0,0 +1,37 @@ +/* + * 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 from 'react'; +import { EuiLink } from '@elastic/eui'; +import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`; + + +export class LearnMoreLinkUi extends React.PureComponent { + render() { + const { href, docPath, text } = this.props; + let url; + if (docPath) { + url = `${esBase}${docPath}`; + } else { + url = href; + } + const content = text ? text : ( + + ); + return ( + + {content} + + ); + + } +} +export const LearnMoreLink = injectI18n(LearnMoreLinkUi); \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/components/optional_label.js b/x-pack/plugins/index_lifecycle_management/public/sections/components/optional_label.js new file mode 100644 index 0000000000000..7b43c5fe2e842 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/components/optional_label.js @@ -0,0 +1,15 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +export const OptionalLabel = () => { + return ( + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/components/phase_error_message.js b/x-pack/plugins/index_lifecycle_management/public/sections/components/phase_error_message.js new file mode 100644 index 0000000000000..4c284494c17fb --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/components/phase_error_message.js @@ -0,0 +1,18 @@ +/* + * 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 from 'react'; +import { EuiBadge } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +export const PhaseErrorMessage = ({ isShowingErrors }) => { + return isShowingErrors ? ( + + + + ) : null; +}; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/cold_phase/cold_phase.container.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/cold_phase/cold_phase.container.js new file mode 100644 index 0000000000000..bf6a506758829 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/cold_phase/cold_phase.container.js @@ -0,0 +1,31 @@ +/* + * 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 { connect } from 'react-redux'; +import { ColdPhase as PresentationComponent } from './cold_phase'; +import { + getPhase, + getPhaseData +} from '../../../../store/selectors'; +import { setPhaseData } from '../../../../store/actions'; +import { + PHASE_COLD, + PHASE_WARM, + PHASE_REPLICA_COUNT +} from '../../../../store/constants'; + +export const ColdPhase = connect( + (state) => ({ + phaseData: getPhase(state, PHASE_COLD), + warmPhaseReplicaCount: getPhaseData(state, PHASE_WARM, PHASE_REPLICA_COUNT) + }), + { + setPhaseData: (key, value) => setPhaseData(PHASE_COLD, key, value), + } +)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/cold_phase/cold_phase.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/cold_phase/cold_phase.js new file mode 100644 index 0000000000000..6b295f822069b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/cold_phase/cold_phase.js @@ -0,0 +1,185 @@ +/* + * 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, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiFormRow, + EuiFieldNumber, + EuiButtonEmpty, + EuiDescribedFormGroup, + EuiButton, +} from '@elastic/eui'; +import { + PHASE_COLD, + PHASE_ENABLED, + PHASE_ROLLOVER_ALIAS, + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_ROLLOVER_MINIMUM_AGE_UNITS, + PHASE_NODE_ATTRS, + PHASE_REPLICA_COUNT, +} from '../../../../store/constants'; +import { ErrableFormRow } from '../../form_errors'; +import { MinAgeInput } from '../min_age_input'; +import { ActiveBadge, PhaseErrorMessage, OptionalLabel } from '../../../components'; +import { NodeAllocation } from '../node_allocation'; + +class ColdPhaseUi extends PureComponent { + static propTypes = { + setPhaseData: PropTypes.func.isRequired, + showNodeDetailsFlyout: PropTypes.func.isRequired, + + isShowingErrors: PropTypes.bool.isRequired, + errors: PropTypes.object.isRequired, + phaseData: PropTypes.shape({ + [PHASE_ENABLED]: PropTypes.bool.isRequired, + [PHASE_ROLLOVER_ALIAS]: PropTypes.string.isRequired, + [PHASE_ROLLOVER_MINIMUM_AGE]: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + .isRequired, + [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: PropTypes.string.isRequired, + [PHASE_NODE_ATTRS]: PropTypes.string.isRequired, + [PHASE_REPLICA_COUNT]: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + }).isRequired, + warmPhaseReplicaCount: PropTypes.number.isRequired, + }; + render() { + const { + setPhaseData, + showNodeDetailsFlyout, + phaseData, + warmPhaseReplicaCount, + errors, + isShowingErrors, + } = this.props; + + return ( + + + + {' '} + {phaseData[PHASE_ENABLED] && !isShowingErrors ? : null} + +
+ } + titleSize="s" + description={ + +

+ +

+ {phaseData[PHASE_ENABLED] ? ( + { + await setPhaseData(PHASE_ENABLED, false); + }} + aria-controls="coldPhaseContent" + > + + + ) : ( + { + await setPhaseData(PHASE_ENABLED, true); + }} + aria-controls="coldPhaseContent" + > + + + )} +
+ } + fullWidth + > +
+ {phaseData[PHASE_ENABLED] ? ( + + + + + + + + + + + + + } + errorKey={PHASE_REPLICA_COUNT} + isShowingErrors={isShowingErrors} + errors={errors} + > + { + await setPhaseData(PHASE_REPLICA_COUNT, e.target.value); + }} + min={0} + /> + + + + + setPhaseData(PHASE_REPLICA_COUNT, warmPhaseReplicaCount)} + > + Set to same number as warm phase + + + + + + ) :
} +
+ + ); + } +} +export const ColdPhase = injectI18n(ColdPhaseUi); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/cold_phase/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/cold_phase/index.js new file mode 100644 index 0000000000000..e0d70ceb57726 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/cold_phase/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { ColdPhase } from './cold_phase.container'; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/delete_phase/delete_phase.container.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/delete_phase/delete_phase.container.js new file mode 100644 index 0000000000000..dcb0f9eb63107 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/delete_phase/delete_phase.container.js @@ -0,0 +1,20 @@ +/* + * 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 { connect } from 'react-redux'; +import { DeletePhase as PresentationComponent } from './delete_phase'; +import { getPhase } from '../../../../store/selectors'; +import { setPhaseData } from '../../../../store/actions'; +import { PHASE_DELETE } from '../../../../store/constants'; + +export const DeletePhase = connect( + state => ({ + phaseData: getPhase(state, PHASE_DELETE) + }), + { + setPhaseData: (key, value) => setPhaseData(PHASE_DELETE, key, value) + } +)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/delete_phase/delete_phase.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/delete_phase/delete_phase.js new file mode 100644 index 0000000000000..698ff73fe5cd3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/delete_phase/delete_phase.js @@ -0,0 +1,121 @@ +/* + * 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, { PureComponent, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import PropTypes from 'prop-types'; +import { MinAgeInput } from '../min_age_input'; + +import { + EuiDescribedFormGroup, + EuiButton, +} from '@elastic/eui'; +import { + PHASE_DELETE, + PHASE_ENABLED, + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_ROLLOVER_MINIMUM_AGE_UNITS, +} from '../../../../store/constants'; +import { ActiveBadge, PhaseErrorMessage } from '../../../components'; + +export class DeletePhase extends PureComponent { + static propTypes = { + setPhaseData: PropTypes.func.isRequired, + isShowingErrors: PropTypes.bool.isRequired, + errors: PropTypes.object.isRequired, + phaseData: PropTypes.shape({ + [PHASE_ENABLED]: PropTypes.bool.isRequired, + [PHASE_ROLLOVER_MINIMUM_AGE]: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string + ]).isRequired, + [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: PropTypes.string.isRequired + }).isRequired + }; + + render() { + const { + setPhaseData, + phaseData, + errors, + isShowingErrors, + } = this.props; + + return ( + + + + {' '} + {phaseData[PHASE_ENABLED] && !isShowingErrors ? ( + + ) : null} + +
+ } + titleSize="s" + description={ + +

+ + +

+ {phaseData[PHASE_ENABLED] ? ( + { + await setPhaseData(PHASE_ENABLED, false); + }} + aria-controls="deletePhaseContent" + > + + + ) : ( + { + await setPhaseData(PHASE_ENABLED, true); + }} + aria-controls="deletePhaseContent" + > + + + )} +
+ } + fullWidth + > +
+ {phaseData[PHASE_ENABLED] ? ( + + ) :
} +
+ + ); + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/delete_phase/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/delete_phase/index.js new file mode 100644 index 0000000000000..5f909ab2c0f79 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/delete_phase/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { DeletePhase } from './delete_phase.container'; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/hot_phase/hot_phase.container.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/hot_phase/hot_phase.container.js new file mode 100644 index 0000000000000..32a7100e3f646 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/hot_phase/hot_phase.container.js @@ -0,0 +1,23 @@ +/* + * 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 { connect } from 'react-redux'; +import { HotPhase as PresentationComponent } from './hot_phase'; +import { getPhase } from '../../../../store/selectors'; +import { setPhaseData } from '../../../../store/actions'; +import { PHASE_HOT } from '../../../../store/constants'; + +export const HotPhase = connect( + state => ({ + phaseData: getPhase(state, PHASE_HOT) + }), + { + setPhaseData: (key, value) => setPhaseData(PHASE_HOT, key, value) + }, +)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/hot_phase/hot_phase.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/hot_phase/hot_phase.js new file mode 100644 index 0000000000000..ef7ac076a8e3b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/hot_phase/hot_phase.js @@ -0,0 +1,255 @@ +/* + * 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, PureComponent } from 'react'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import PropTypes from 'prop-types'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiFieldNumber, + EuiSelect, + EuiSwitch, + EuiFormRow, + EuiDescribedFormGroup, +} from '@elastic/eui'; +import { LearnMoreLink, ActiveBadge, PhaseErrorMessage } from '../../../components'; +import { + PHASE_HOT, + PHASE_ROLLOVER_ALIAS, + PHASE_ROLLOVER_MAX_AGE, + PHASE_ROLLOVER_MAX_AGE_UNITS, + PHASE_ROLLOVER_MAX_SIZE_STORED, + PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, + PHASE_ROLLOVER_ENABLED, + MAX_SIZE_TYPE_DOCUMENT +} from '../../../../store/constants'; + +import { ErrableFormRow } from '../../form_errors'; + +class HotPhaseUi extends PureComponent { + static propTypes = { + setPhaseData: PropTypes.func.isRequired, + + isShowingErrors: PropTypes.bool.isRequired, + errors: PropTypes.object.isRequired, + phaseData: PropTypes.shape({ + [PHASE_ROLLOVER_ALIAS]: PropTypes.string.isRequired, + [PHASE_ROLLOVER_MAX_AGE]: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string + ]).isRequired, + [PHASE_ROLLOVER_MAX_AGE_UNITS]: PropTypes.string.isRequired, + [PHASE_ROLLOVER_MAX_SIZE_STORED]: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string + ]).isRequired, + [PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]: PropTypes.string.isRequired + }).isRequired + }; + + render() { + const { + setPhaseData, + phaseData, + isShowingErrors, + errors, + intl + } = this.props; + + return ( + + + + {' '} + {isShowingErrors ? null : } + +
+ } + titleSize="s" + description={ + +

+ +

+
+ } + fullWidth + > + +

+ +

+ + } + docPath="indices-rollover-index.html" + /> + + + } + > + { + await setPhaseData(PHASE_ROLLOVER_ENABLED, e.target.checked); + }} + label={intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.hotPhase.enableRolloverLabel', + defaultMessage: 'Enable rollover' + })} + /> +
+ {phaseData[PHASE_ROLLOVER_ENABLED] ? ( + + + + + + { + await setPhaseData( + PHASE_ROLLOVER_MAX_SIZE_STORED, + e.target.value + ); + }} + min={1} + /> + + + + + { + await setPhaseData( + PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, + e.target.value + ); + }} + options={[ + { value: 'gb', text: intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.hotPhase.gigabytesLabel', + defaultMessage: 'gigabytes' + }) }, + { value: MAX_SIZE_TYPE_DOCUMENT, text: intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.hotPhase.documentsLabel', + defaultMessage: 'documents' + }) } + ]} + /> + + + + + + + + { + await setPhaseData(PHASE_ROLLOVER_MAX_AGE, e.target.value); + }} + min={1} + /> + + + + + { + await setPhaseData( + PHASE_ROLLOVER_MAX_AGE_UNITS, + e.target.value + ); + }} + options={[ + { value: 'd', text: intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.hotPhase.daysLabel', + defaultMessage: 'days' + }) }, + { value: 'h', text: intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.hotPhase.hoursLabel', + defaultMessage: 'hours' + }) }, + ]} + /> + + + + + ) : null} + + ); + } +} +export const HotPhase = injectI18n(HotPhaseUi); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/hot_phase/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/hot_phase/index.js new file mode 100644 index 0000000000000..114e34c3ef4d0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/hot_phase/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { HotPhase } from './hot_phase.container'; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js new file mode 100644 index 0000000000000..80e990095ae4c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js @@ -0,0 +1,70 @@ +/* + * 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 from 'react'; +import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_ROLLOVER_MINIMUM_AGE_UNITS, +} from '../../../store/constants'; +import { ErrableFormRow } from '../form_errors'; +import { injectI18n } from '@kbn/i18n/react'; +const MinAgeInputUi = props => { + const { errors, phaseData, phase, setPhaseData, isShowingErrors, intl } = props; + return ( + + + + { + setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE, e.target.value); + }} + min={1} + /> + + + + + setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE_UNITS, e.target.value)} + options={[ + { + value: 'd', + text: intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.editPolicy.daysLabel', + defaultMessage: 'days', + }), + }, + { + value: 'h', + text: intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.editPolicy.hoursLabel', + defaultMessage: 'hours', + }), + }, + ]} + /> + + + + ); +}; +export const MinAgeInput = injectI18n(MinAgeInputUi); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_allocation/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_allocation/index.js new file mode 100644 index 0000000000000..dcf6a0787755d --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_allocation/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { NodeAllocation } from './node_allocation.container'; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_allocation/node_allocation.container.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_allocation/node_allocation.container.js new file mode 100644 index 0000000000000..d309c53eb295c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_allocation/node_allocation.container.js @@ -0,0 +1,24 @@ +/* + * 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 { connect } from 'react-redux'; +import { NodeAllocation as PresentationComponent } from './node_allocation'; +import { + getNodeOptions, +} from '../../../../store/selectors'; +import { fetchNodes } from '../../../../store/actions'; + +export const NodeAllocation = connect( + (state) => ({ + nodeOptions: getNodeOptions(state), + }), + { + fetchNodes + } +)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_allocation/node_allocation.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_allocation/node_allocation.js new file mode 100644 index 0000000000000..a6b3eaf02ee60 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_allocation/node_allocation.js @@ -0,0 +1,116 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { EuiSelect, EuiButtonEmpty, EuiCallOut, EuiSpacer, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { PHASE_NODE_ATTRS } from '../../../../store/constants'; +import { ErrableFormRow } from '../../form_errors'; +import { LearnMoreLink } from '../../../components/learn_more_link'; +const learnMoreLinks = ( + + + + + } + docPath="shards-allocation.html" + /> + +); +class NodeAllocationUi extends Component { + componentDidMount() { + this.props.fetchNodes(); + } + render() { + const { + phase, + setPhaseData, + intl, + isShowingErrors, + phaseData, + showNodeDetailsFlyout, + nodeOptions, + errors + } = this.props; + if (!nodeOptions) { + return ( + + + + + ); + } + if (!nodeOptions.length) { + return ( + + + } + color="warning" + > + + {learnMoreLinks} + + + + + ); + } + return ( + + + { + await setPhaseData(PHASE_NODE_ATTRS, e.target.value); + }} + /> + + + {!!phaseData[PHASE_NODE_ATTRS] ? ( + showNodeDetailsFlyout(phaseData[PHASE_NODE_ATTRS])} + > + + + ) :
} + {learnMoreLinks} + + + ); + } +} +export const NodeAllocation = injectI18n(NodeAllocationUi); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_attrs_details/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_attrs_details/index.js new file mode 100644 index 0000000000000..885e965c46c4b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_attrs_details/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { NodeAttrsDetails } from './node_attrs_details.container'; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_attrs_details/node_attrs_details.container.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_attrs_details/node_attrs_details.container.js new file mode 100644 index 0000000000000..8887b13740e04 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_attrs_details/node_attrs_details.container.js @@ -0,0 +1,17 @@ +/* + * 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 { connect } from 'react-redux'; +import { NodeAttrsDetails as PresentationComponent } from './node_attrs_details'; +import { getNodeDetails } from '../../../../store/selectors'; +import { fetchNodeDetails } from '../../../../store/actions'; + +export const NodeAttrsDetails = connect( + (state, ownProps) => ({ + details: getNodeDetails(state, ownProps.selectedNodeAttrs), + }), + { fetchNodeDetails } +)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_attrs_details/node_attrs_details.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_attrs_details/node_attrs_details.js new file mode 100644 index 0000000000000..66329e448af6c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/node_attrs_details/node_attrs_details.js @@ -0,0 +1,77 @@ +/* + * 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, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; + +import { + EuiFlyoutBody, + EuiFlyout, + EuiTitle, + EuiInMemoryTable, + EuiSpacer, + EuiPortal, +} from '@elastic/eui'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; + + +export class NodeAttrsDetailsUi extends PureComponent { + static propTypes = { + fetchNodeDetails: PropTypes.func.isRequired, + close: PropTypes.func.isRequired, + + details: PropTypes.array, + selectedNodeAttrs: PropTypes.string.isRequired, + }; + + componentWillMount() { + this.props.fetchNodeDetails(this.props.selectedNodeAttrs); + } + + render() { + const { selectedNodeAttrs, details, close, intl } = this.props; + + return ( + + + + +

+ +

+
+ + +
+
+
+ ); + } +} +export const NodeAttrsDetails = injectI18n(NodeAttrsDetailsUi); + diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/policy_json_flyout.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/policy_json_flyout.js new file mode 100644 index 0000000000000..6e553dfd36efa --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/policy_json_flyout.js @@ -0,0 +1,85 @@ +/* + * 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, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { toastNotifications } from 'ui/notify'; +import copy from 'copy-to-clipboard'; + +import { + EuiButton, + EuiCodeBlock, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyout, + EuiFlyoutHeader, + EuiPortal, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; + + +export class PolicyJsonFlyoutUi extends PureComponent { + static propTypes = { + close: PropTypes.func.isRequired, + lifecycle: PropTypes.object.isRequired, + }; + getEsJson({ phases }) { + return JSON.stringify({ + policy: { + phases + } + }, null, 4); + } + copyToClipboard(lifecycle) { + const { intl } = this.props; + copy(this.getEsJson(lifecycle)); + toastNotifications.add(intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.editPolicy.policyJsonFlyout.copiedToClipboardMessage', + defaultMessage: 'JSON copied to clipboard', + })); + } + render() { + const { lifecycle, close, policyName } = this.props; + + return ( + + + + +

+ +

+
+
+ + + + {this.getEsJson(lifecycle)} + + + + + this.copyToClipboard(lifecycle)}> + + + +
+
+ ); + } +} +export const PolicyJsonFlyout = injectI18n(PolicyJsonFlyoutUi); + diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/warm_phase/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/warm_phase/index.js new file mode 100644 index 0000000000000..7eb5def486c87 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/warm_phase/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { WarmPhase } from './warm_phase.container'; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/warm_phase/warm_phase.container.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/warm_phase/warm_phase.container.js new file mode 100644 index 0000000000000..5b66fcae24ec2 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/warm_phase/warm_phase.container.js @@ -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 { connect } from 'react-redux'; +import { WarmPhase as PresentationComponent } from './warm_phase'; +import { + getPhase, +} from '../../../../store/selectors'; +import { setPhaseData } from '../../../../store/actions'; +import { PHASE_WARM, PHASE_HOT, PHASE_ROLLOVER_ENABLED } from '../../../../store/constants'; + +export const WarmPhase = connect( + state => ({ + phaseData: getPhase(state, PHASE_WARM), + hotPhaseRolloverEnabled: getPhase(state, PHASE_HOT)[PHASE_ROLLOVER_ENABLED], + }), + { + setPhaseData: (key, value) => setPhaseData(PHASE_WARM, key, value), + } +)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/warm_phase/warm_phase.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/warm_phase/warm_phase.js new file mode 100644 index 0000000000000..3c6c0449ba391 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/components/warm_phase/warm_phase.js @@ -0,0 +1,346 @@ +/* + * 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, PureComponent } from 'react'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import PropTypes from 'prop-types'; +import { + EuiTextColor, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiFormRow, + EuiFieldNumber, + EuiSwitch, + EuiDescribedFormGroup, + EuiButton, +} from '@elastic/eui'; +import { + PHASE_WARM, + PHASE_ENABLED, + WARM_PHASE_ON_ROLLOVER, + PHASE_ROLLOVER_ALIAS, + PHASE_FORCE_MERGE_ENABLED, + PHASE_FORCE_MERGE_SEGMENTS, + PHASE_NODE_ATTRS, + PHASE_PRIMARY_SHARD_COUNT, + PHASE_REPLICA_COUNT, + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_ROLLOVER_MINIMUM_AGE_UNITS, + PHASE_SHRINK_ENABLED, +} from '../../../../store/constants'; +import { NodeAllocation } from '../node_allocation'; +import { ErrableFormRow } from '../../form_errors'; +import { LearnMoreLink, ActiveBadge, PhaseErrorMessage, OptionalLabel } from '../../../components'; +import { MinAgeInput } from '../min_age_input'; +class WarmPhaseUi extends PureComponent { + static propTypes = { + setPhaseData: PropTypes.func.isRequired, + showNodeDetailsFlyout: PropTypes.func.isRequired, + + isShowingErrors: PropTypes.bool.isRequired, + errors: PropTypes.object.isRequired, + phaseData: PropTypes.shape({ + [PHASE_ENABLED]: PropTypes.bool.isRequired, + [WARM_PHASE_ON_ROLLOVER]: PropTypes.bool.isRequired, + [PHASE_ROLLOVER_ALIAS]: PropTypes.string.isRequired, + [PHASE_FORCE_MERGE_ENABLED]: PropTypes.bool.isRequired, + [PHASE_FORCE_MERGE_SEGMENTS]: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + .isRequired, + [PHASE_NODE_ATTRS]: PropTypes.string.isRequired, + [PHASE_PRIMARY_SHARD_COUNT]: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + .isRequired, + [PHASE_REPLICA_COUNT]: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + [PHASE_ROLLOVER_MINIMUM_AGE]: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + .isRequired, + [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: PropTypes.string.isRequired, + }).isRequired, + }; + render() { + const { + setPhaseData, + showNodeDetailsFlyout, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, + intl, + } = this.props; + const shrinkLabel = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', + defaultMessage: 'Shrink index', + }); + const moveToWarmPhaseOnRolloverLabel = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', + defaultMessage: 'Move to warm phase on rollover', + }); + const forcemergeLabel = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.warmPhase.forceMergeDataLabel', + defaultMessage: 'Force merge data', + }); + return ( + + + + + {' '} + {phaseData[PHASE_ENABLED] && !isShowingErrors ? : null} + +
+ } + titleSize="s" + description={ + +

+ +

+ {phaseData[PHASE_ENABLED] ? ( + { + await setPhaseData(PHASE_ENABLED, false); + }} + aria-controls="warmPhaseContent" + > + + + ) : ( + { + await setPhaseData(PHASE_ENABLED, true); + }} + aria-controls="warmPhaseContent" + > + + + )} +
+ } + fullWidth + > + +
+ {phaseData[PHASE_ENABLED] ? ( + + {hotPhaseRolloverEnabled ? ( + + { + await setPhaseData(WARM_PHASE_ON_ROLLOVER, e.target.checked); + }} + /> + + ) : null} + {!phaseData[WARM_PHASE_ON_ROLLOVER] ? ( + + ) : null} + + + + + + + + + + + + } + errorKey={PHASE_REPLICA_COUNT} + isShowingErrors={isShowingErrors} + errors={errors} + > + { + await setPhaseData(PHASE_REPLICA_COUNT, e.target.value); + }} + min={0} + /> + + + + + + + ) : null } +
+
+ + {phaseData[PHASE_ENABLED] ? ( + + + +

+ } + description={ + + {' '} + + + } + fullWidth + titleSize="xs" + > + + { + await setPhaseData(PHASE_SHRINK_ENABLED, e.target.checked); + }} + label={shrinkLabel} + aria-label={shrinkLabel} + aria-controls="shrinkContent" + /> +
+ {phaseData[PHASE_SHRINK_ENABLED] ? ( + + + + + + { + await setPhaseData(PHASE_PRIMARY_SHARD_COUNT, e.target.value); + }} + min={1} + /> + + + + + + ) : null} +
+
+
+ + +

+ } + description={ + + {' '} + + + } + titleSize="xs" + fullWidth + > + { + await setPhaseData(PHASE_FORCE_MERGE_ENABLED, e.target.checked); + }} + aria-controls="forcemergeContent" + /> + + +
+ {phaseData[PHASE_FORCE_MERGE_ENABLED] ? ( + + { + await setPhaseData(PHASE_FORCE_MERGE_SEGMENTS, e.target.value); + }} + min={1} + /> + + ) : null} +
+
+
+ ) : null} +
+ ); + } +} +export const WarmPhase = injectI18n(WarmPhaseUi); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/edit_policy.container.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/edit_policy.container.js new file mode 100644 index 0000000000000..5b3a97bb13213 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/edit_policy.container.js @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { EditPolicy as PresentationComponent } from './edit_policy'; +import { + getSaveAsNewPolicy, + getSelectedPolicy, + validateLifecycle, + getLifecycle, + getPolicies, + isPolicyListLoaded, + getIsNewPolicy, + getSelectedOriginalPolicyName +} from '../../store/selectors'; +import { + setSelectedPolicy, + setSelectedPolicyName, + setSaveAsNewPolicy, + saveLifecyclePolicy, + fetchPolicies, +} from '../../store/actions'; +import { findFirstError } from '../../services/find_errors'; + +export const EditPolicy = connect( + state => { + const errors = validateLifecycle(state); + const firstError = findFirstError(errors); + return { + firstError, + errors, + selectedPolicy: getSelectedPolicy(state), + saveAsNewPolicy: getSaveAsNewPolicy(state), + lifecycle: getLifecycle(state), + policies: getPolicies(state), + isPolicyListLoaded: isPolicyListLoaded(state), + isNewPolicy: getIsNewPolicy(state), + originalPolicyName: getSelectedOriginalPolicyName(state), + }; + }, + { + setSelectedPolicy, + setSelectedPolicyName, + setSaveAsNewPolicy, + saveLifecyclePolicy, + fetchPolicies, + } +)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/edit_policy.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/edit_policy.js new file mode 100644 index 0000000000000..d06b687d53788 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/edit_policy.js @@ -0,0 +1,351 @@ +/* + * 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, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { toastNotifications } from 'ui/notify'; +import { goToPolicyList } from '../../services/navigation'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { + EuiPage, + EuiPageBody, + EuiFieldText, + EuiPageContent, + EuiFormRow, + EuiTitle, + EuiText, + EuiSpacer, + EuiSwitch, + EuiHorizontalRule, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiDescribedFormGroup, +} from '@elastic/eui'; +import { HotPhase } from './components/hot_phase'; +import { WarmPhase } from './components/warm_phase'; +import { DeletePhase } from './components/delete_phase'; +import { ColdPhase } from './components/cold_phase'; +import { + PHASE_HOT, + PHASE_COLD, + PHASE_DELETE, + PHASE_WARM, + STRUCTURE_POLICY_NAME, +} from '../../store/constants'; +import { findFirstError } from '../../services/find_errors'; +import { NodeAttrsDetails } from './components/node_attrs_details'; +import { PolicyJsonFlyout } from './components/policy_json_flyout'; +import { ErrableFormRow } from './form_errors'; +import { LearnMoreLink } from '../components'; +class EditPolicyUi extends Component { + static propTypes = { + selectedPolicy: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, + }; + + constructor(props) { + super(props); + this.state = { + isShowingErrors: false, + isShowingNodeDetailsFlyout: false, + selectedNodeAttrsForDetails: undefined, + isShowingPolicyJsonFlyout: false, + }; + } + selectPolicy = policyName => { + const { setSelectedPolicy, policies } = this.props; + const selectedPolicy = policies.find(policy => { + return policy.name === policyName; + }); + if (selectedPolicy) { + setSelectedPolicy(selectedPolicy); + } + }; + componentDidMount() { + window.scrollTo(0, 0); + const { + isPolicyListLoaded, + fetchPolicies, + match: { + params: { policyName }, + } = { params: {} }, + } = this.props; + if (policyName) { + const decodedPolicyName = decodeURIComponent(policyName); + if (isPolicyListLoaded) { + this.selectPolicy(decodedPolicyName); + } else { + fetchPolicies(true, () => { + this.selectPolicy(decodedPolicyName); + }); + } + } else { + this.props.setSelectedPolicy(null); + } + } + backToPolicyList = () => { + this.props.setSelectedPolicy(null); + goToPolicyList(); + }; + submit = async () => { + const { intl } = this.props; + this.setState({ isShowingErrors: true }); + const { saveLifecyclePolicy, lifecycle, saveAsNewPolicy, firstError } = this.props; + if (firstError) { + toastNotifications.addDanger( + intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage', + defaultMessage: 'Please fix the errors on this page.', + }) + ); + const element = document.getElementById(`${firstError}-row`); + if (element) { + element.scrollIntoView(); + } + } else { + const success = await saveLifecyclePolicy(lifecycle, saveAsNewPolicy); + if (success) { + this.backToPolicyList(); + } + } + }; + + showNodeDetailsFlyout = selectedNodeAttrsForDetails => { + this.setState({ isShowingNodeDetailsFlyout: true, selectedNodeAttrsForDetails }); + }; + showPolicyJsonFlyout = () => { + this.setState({ isShowingPolicyJsonFlyout: true }); + }; + render() { + const { + intl, + selectedPolicy, + errors, + setSaveAsNewPolicy, + saveAsNewPolicy, + setSelectedPolicyName, + isNewPolicy, + lifecycle, + originalPolicyName, + } = this.props; + const selectedPolicyName = selectedPolicy.name; + const { isShowingErrors } = this.state; + + return ( + + + + +

+ {isNewPolicy + ? intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', + defaultMessage: 'Create an index lifecycle policy', + }) + : intl.formatMessage( + { + id: 'xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', + defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', + }, + { originalPolicyName } + )} +

+
+
+ + +

+ + {' '} + + } + /> +

+
+ + + {isNewPolicy ? null : ( + + + +

+ + + + .{' '} + +

+
+ +
+ + { + await setSaveAsNewPolicy(e.target.checked); + }} + label={ + + + + } + /> + +
+ )} + {saveAsNewPolicy || isNewPolicy ? ( + + + + +
+ } + titleSize="s" + fullWidth + > + + { + await setSelectedPolicyName(e.target.value); + }} + /> + + + ) : null} + + + + + + + + + + + + + + + + {saveAsNewPolicy ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + {this.state.isShowingNodeDetailsFlyout ? ( + this.setState({ isShowingNodeDetailsFlyout: false })} + /> + ) : null} + {this.state.isShowingPolicyJsonFlyout ? ( + this.setState({ isShowingPolicyJsonFlyout: false })} + /> + ) : null} +
+ + + + ); + } +} +export const EditPolicy = injectI18n(EditPolicyUi); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/form_errors.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/form_errors.js new file mode 100644 index 0000000000000..fc3c29c4beb0c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/form_errors.js @@ -0,0 +1,36 @@ +/* + * 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, { cloneElement, Children, Fragment } from 'react'; +import { EuiFormRow } from '@elastic/eui'; + +export const ErrableFormRow = ({ + errorKey, + isShowingErrors, + errors, + children, + ...rest +}) => { + return ( + 0 + } + error={errors[errorKey]} + {...rest} + > + + {Children.map(children, child => cloneElement(child, { + isInvalid: isShowingErrors && errors[errorKey].length > 0, + }))} + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/index.js new file mode 100644 index 0000000000000..e4154a76289b6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/edit_policy/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { EditPolicy } from './edit_policy.container'; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/no_match/components/no_match/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/no_match/components/no_match/index.js new file mode 100644 index 0000000000000..2cdbc4a7094a8 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/no_match/components/no_match/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { NoMatch } from './no_match'; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/no_match/components/no_match/no_match.js b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/no_match/components/no_match/no_match.js new file mode 100644 index 0000000000000..3f8bc17c2b193 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/no_match/components/no_match/no_match.js @@ -0,0 +1,18 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const NoMatch = () => ( +
+ +
+); + diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/no_match/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/no_match/index.js new file mode 100644 index 0000000000000..63e8cdebd9771 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/no_match/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { NoMatch } from './components/no_match'; diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js new file mode 100644 index 0000000000000..106a9887e833d --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js @@ -0,0 +1,216 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { get, find } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { LearnMoreLink } from '../../../components/learn_more_link'; +import { + EuiCallOut, + EuiSelect, + EuiForm, + EuiFormRow, + EuiOverlayMask, + EuiConfirmModal, + EuiFieldText, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +import { addLifecyclePolicyToTemplate, loadIndexTemplates } from '../../../../services/api'; +import { showApiError } from '../../../../services/api_errors'; +export class AddPolicyToTemplateConfirmModalUi extends Component { + state = { + templates: [] + } + async componentDidMount() { + const templates = await loadIndexTemplates(); + this.setState({ templates }); + } + addPolicyToTemplate = async () => { + const { intl, policy, callback, onCancel } = this.props; + const { templateName, aliasName } = this.state; + const policyName = policy.name; + if (!templateName) { + this.setState({ templateError: i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyConfirmModal.noTemplateSelectedErrorMessage', + { defaultMessage: 'You must select an index template.' }) }); + return; + } + try { + await addLifecyclePolicyToTemplate({ + policyName, + templateName, + aliasName + }); + const message = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyConfirmModal.successMessage', + defaultMessage: 'Added policy {policyName} to index template {templateName}', + }, { policyName, templateName }); + toastNotifications.addSuccess(message); + onCancel(); + } catch (e) { + const title = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyConfirmModal.errorMessage', + defaultMessage: 'Error adding policy "{policyName}" to index template {templateName}', + }, { policyName, templateName }); + showApiError(e, title); + } + if (callback) { + callback(); + } + }; + renderTemplateHasPolicyWarning() { + const selectedTemplate = this.getSelectedTemplate(); + const existingPolicyName = get(selectedTemplate, 'settings.index.lifecycle.name'); + if (!existingPolicyName) { + return; + } + return ( + + + } + color="warning" + > + + + + + ); + } + getSelectedTemplate() { + const { templates, templateName } = this.state; + return find(templates, template => template.name === templateName); + } + renderForm() { + const { templates, templateName, templateError } = this.state; + const options = templates.map(({ name }) => { + return { + value: name, + text: name, + }; + }); + options.unshift({ + value: '', + text: i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyConfirmModal.chooseTemplateMessage', + { + defaultMessage: 'Select an index template', + } + ), + }); + return ( + + {this.renderTemplateHasPolicyWarning()} + + } + > + { + this.setState({ templateError: null, templateName: e.target.value }); + }} + /> + + {this.renderAliasFormElement()} + + ); + } + renderAliasFormElement = () => { + const { aliasName } = this.state; + const { policy } = this.props; + const showAliasTextInput = + policy && get(policy, 'policy.phases.hot.actions.rollover'); + if (!showAliasTextInput) { + return null; + } + return ( + + } + > + { + this.setState({ aliasName: e.target.value }); + }} + /> + + ); + }; + render() { + const { intl, policy, onCancel } = this.props; + const title = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyConfirmModal.title', + defaultMessage: 'Add policy "{name}" to index template', + }, { name: policy.name }); + return ( + + + +

+ {' '} + } + /> +

+
+ + {this.renderForm()} +
+
+ ); + } +} +export const AddPolicyToTemplateConfirmModal = injectI18n(AddPolicyToTemplateConfirmModalUi); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/confirm_delete.js b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/confirm_delete.js new file mode 100644 index 0000000000000..cdaf60d1f829b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/confirm_delete.js @@ -0,0 +1,74 @@ +/* + * 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, { Component } from 'react'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +import { deletePolicy } from '../../../../services/api'; +import { showApiError } from '../../../../services/api_errors'; +export class ConfirmDeleteUi extends Component { + deletePolicy = async () => { + const { intl, policyToDelete, callback } = this.props; + const policyName = policyToDelete.name; + + try { + await deletePolicy(policyName); + const message = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.confirmDelete.successMessage', + defaultMessage: 'Deleted policy {policyName}', + }, { policyName }); + toastNotifications.addSuccess(message); + } catch (e) { + const title = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.confirmDelete.errorMessage', + defaultMessage: 'Error deleting policy {policyName}', + }, { policyName }); + showApiError(e, title); + } + if (callback) { + callback(); + } + }; + render() { + const { intl, policyToDelete, onCancel } = this.props; + const title = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.confirmDelete.title', + defaultMessage: 'Delete policy "{name}"', + }, { name: policyToDelete.name }); + return ( + + + } + confirmButtonText={ + + } + buttonColor="danger" + onClose={onCancel} + > +
+ +
+
+
+ ); + } +} +export const ConfirmDelete = injectI18n(ConfirmDeleteUi); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/index.js new file mode 100644 index 0000000000000..1e10c49443cef --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { PolicyTable } from './policy_table.container'; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/policy_table.container.js b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/policy_table.container.js new file mode 100644 index 0000000000000..0688284606b1f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/policy_table.container.js @@ -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 { connect } from 'react-redux'; +import { PolicyTable as PresentationComponent } from './policy_table'; +import { + fetchPolicies, + policyFilterChanged, + policyPageChanged, + policyPageSizeChanged, + policySortChanged, +} from '../../../../store/actions'; +import { + getPolicies, + getPageOfPolicies, + getPolicyPager, + getPolicyFilter, + getPolicySort, + isPolicyListLoaded, +} from '../../../../store/selectors'; + +const mapDispatchToProps = dispatch => { + return { + policyFilterChanged: filter => { + dispatch(policyFilterChanged({ filter })); + }, + policyPageChanged: pageNumber => { + dispatch(policyPageChanged({ pageNumber })); + }, + policyPageSizeChanged: pageSize => { + dispatch(policyPageSizeChanged({ pageSize })); + }, + policySortChanged: (sortField, isSortAscending) => { + dispatch(policySortChanged({ sortField, isSortAscending })); + }, + fetchPolicies: withIndices => { + dispatch(fetchPolicies(withIndices)); + }, + }; +}; + +export const PolicyTable = connect( + state => ({ + totalNumberOfPolicies: getPolicies(state).length, + policies: getPageOfPolicies(state), + pager: getPolicyPager(state), + filter: getPolicyFilter(state), + sortField: getPolicySort(state).sortField, + isSortAscending: getPolicySort(state).isSortAscending, + policyListLoaded: isPolicyListLoaded(state), + }), + mapDispatchToProps +)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/policy_table.js b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/policy_table.js new file mode 100644 index 0000000000000..36fc2af7b0040 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/components/policy_table/policy_table.js @@ -0,0 +1,498 @@ +/* + * 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, { Component, Fragment } from 'react'; +import moment from 'moment-timezone'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { BASE_PATH } from '../../../../../common/constants'; +import { NoMatch } from '../no_match'; +import { getPolicyPath } from '../../../../services/navigation'; +import { flattenPanelTree } from '../../../../services/flatten_panel_tree'; +import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import { + EuiBetaBadge, + EuiButton, + EuiLink, + EuiEmptyPrompt, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPage, + EuiPopover, + EuiContextMenu, + EuiSpacer, + EuiTable, + EuiTableBody, + EuiTableHeader, + EuiTableHeaderCell, + EuiTablePagination, + EuiTableRow, + EuiTableRowCell, + EuiTitle, + EuiText, + EuiPageBody, + EuiPageContent, +} from '@elastic/eui'; + +import { ConfirmDelete } from './confirm_delete'; +import { AddPolicyToTemplateConfirmModal } from './add_policy_to_template_confirm_modal'; +import { getFilteredIndicesUri } from '../../../../../../index_management/public/services/navigation'; +const COLUMNS = { + name: { + label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.nameHeader', { + defaultMessage: 'Name', + }), + }, + coveredIndices: { + label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.coveredIndicesHeader', { + defaultMessage: 'Covered indices', + }), + width: 120, + }, + version: { + label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.versionHeader', { + defaultMessage: 'Version', + }), + width: 120, + }, + modified_date: { + label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.modifiedDateHeader', { + defaultMessage: 'Modified date', + }), + width: 200, + }, +}; + +export class PolicyTableUi extends Component { + constructor(props) { + super(props); + + this.state = { + selectedPoliciesMap: {}, + renderConfirmModal: null, + }; + } + componentDidMount() { + this.props.fetchPolicies(true); + } + renderEmpty() { + return ( + + + + } + body={ + +

+ +

+
+ } + actions={this.renderCreatePolicyButton()} + /> + ); + } + renderDeleteConfirmModal = () => { + const { policyToDelete } = this.state; + if (!policyToDelete) { + return null; + } + return ( + this.setState({ renderConfirmModal: null, policyToDelete: null })} + /> + ); + }; + renderAddPolicyToTemplateConfirmModal = () => { + const { policyToAddToTemplate } = this.state; + if (!policyToAddToTemplate) { + return null; + } + return ( + this.setState({ renderConfirmModal: null, policyToAddToTemplate: null })} + /> + ); + }; + handleDelete = () => { + this.props.fetchPolicies(true); + this.setState({ renderDeleteConfirmModal: null, policyToDelete: null }); + }; + onSort = column => { + const { sortField, isSortAscending, policySortChanged } = this.props; + const newIsSortAscending = sortField === column ? !isSortAscending : true; + policySortChanged(column, newIsSortAscending); + }; + + buildHeader() { + const { sortField, isSortAscending } = this.props; + const headers = Object.entries(COLUMNS).map(([fieldName, { label, width }]) => { + const isSorted = sortField === fieldName; + return ( + this.onSort(fieldName)} + isSorted={isSorted} + isSortAscending={isSortAscending} + data-test-subj={`policyTableHeaderCell-${fieldName}`} + className={'policyTable__header--' + fieldName} + width={width} + > + {label} + + ); + }); + headers.push( + + ); + return headers; + } + + buildRowCell(fieldName, value) { + if (fieldName === 'name') { + return ( + + {value} + + ); + } else if (fieldName === 'coveredIndices') { + return ( + + {value ? value.length : '0'} + + ); + } else if (fieldName === 'modified_date' && value) { + return moment(value).format('YYYY-MM-DD HH:mm:ss'); + } + return value; + } + renderCreatePolicyButton() { + return ( + + + + ); + } + renderConfirmModal() { + const { renderConfirmModal } = this.state; + if (renderConfirmModal) { + return renderConfirmModal(); + } else { + return null; + } + } + buildActionPanelTree(policy) { + const { intl } = this.props; + const hasCoveredIndices = Boolean(policy.coveredIndices && policy.coveredIndices.length); + + const viewIndicesLabel = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.viewIndicesButtonText', + defaultMessage: 'View indices linked to policy', + }); + const addPolicyToTemplateLabel = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.addPolicyToTemplateButtonText', + defaultMessage: 'Add policy to index template', + }); + const deletePolicyLabel = intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonText', + defaultMessage: 'Delete policy', + }); + const deletePolicyTooltip = hasCoveredIndices + ? intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonDisabledTooltip', + defaultMessage: 'You cannot delete a policy that is being used by an index', + }) + : null; + const items = []; + if (hasCoveredIndices) { + items.push({ + name: viewIndicesLabel, + icon: 'list', + onClick: () => { + window.location.hash = getFilteredIndicesUri(`ilm.policy:${policy.name}`); + }, + }); + } + items.push({ + name: addPolicyToTemplateLabel, + icon: 'plusInCircle', + onClick: () => + this.setState({ + renderConfirmModal: this.renderAddPolicyToTemplateConfirmModal, + policyToAddToTemplate: policy, + }), + }); + items.push({ + name: deletePolicyLabel, + disabled: hasCoveredIndices, + icon: 'trash', + toolTipContent: deletePolicyTooltip, + onClick: () => + this.setState({ + renderConfirmModal: this.renderDeleteConfirmModal, + policyToDelete: policy, + }), + }); + const panelTree = { + id: 0, + title: intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.policyActionsMenu.panelTitle', + defaultMessage: 'Policy options', + }), + items, + }; + return flattenPanelTree(panelTree); + } + togglePolicyPopover = policy => { + if (this.isPolicyPopoverOpen(policy)) { + this.closePolicyPopover(policy); + } else { + this.openPolicyPopover(policy); + } + }; + isPolicyPopoverOpen = policy => { + return this.state.policyPopover === policy.name; + }; + closePolicyPopover = policy => { + if (this.isPolicyPopoverOpen(policy)) { + this.setState({ policyPopover: null }); + } + }; + openPolicyPopover = policy => { + this.setState({ policyPopover: policy.name }); + }; + buildRowCells(policy) { + const { intl } = this.props; + const { name } = policy; + const cells = Object.entries(COLUMNS).map(([fieldName, { width }]) => { + const value = policy[fieldName]; + return ( + + {this.buildRowCell(fieldName, value)} + + ); + }); + const button = ( + this.togglePolicyPopover(policy)} + iconType="arrowDown" + fill + > + {intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.actionsButtonText', + defaultMessage: 'Actions', + })} + + ); + cells.push( + + this.closePolicyPopover(policy)} + panelPaddingSize="none" + withTitle + anchorPosition="rightUp" + repositionOnScroll + > + + + + ); + return cells; + } + + buildRows() { + const { policies = [] } = this.props; + return policies.map(policy => { + const { name } = policy; + return {this.buildRowCells(policy)}; + }); + } + + renderPager() { + const { pager, policyPageChanged, policyPageSizeChanged } = this.props; + return ( + + ); + } + + onItemSelectionChanged = selectedPolicies => { + this.setState({ selectedPolicies }); + }; + + render() { + const { + totalNumberOfPolicies, + policyFilterChanged, + filter, + intl, + policyListLoaded, + } = this.props; + const { selectedPoliciesMap } = this.state; + const numSelected = Object.keys(selectedPoliciesMap).length; + let content; + let tableContent; + if (totalNumberOfPolicies || !policyListLoaded) { + if (!policyListLoaded) { + tableContent = ; + } else if (totalNumberOfPolicies > 0) { + tableContent = ( + + {this.buildHeader()} + {this.buildRows()} + + ); + } else { + tableContent = ; + } + content = ( + + + {numSelected > 0 ? ( + + this.setState({ showDeleteConfirmation: true })} + > + Delete {numSelected} polic + {numSelected > 1 ? 'ies' : 'y'} + + + ) : null} + + { + policyFilterChanged(event.target.value); + }} + data-test-subj="policyTableFilterInput" + placeholder={intl.formatMessage({ + id: 'xpack.indexLifecycleMgmt.policyTable.systempoliciesSearchInputPlaceholder', + defaultMessage: 'Search', + })} + aria-label="Search policies" + /> + + + + {tableContent} + + ); + } else { + content = this.renderEmpty(); + } + + return ( + + + +
+ {this.renderConfirmModal()} + {totalNumberOfPolicies || !policyListLoaded ? ( + + + + + + +

+ +

+
+
+ + + + +
+
+ {totalNumberOfPolicies ? ( + {this.renderCreatePolicyButton()} + ) : null} +
+ + +

+ +

+
+
+ ) : null} + + {content} + + {totalNumberOfPolicies && totalNumberOfPolicies > 10 ? this.renderPager() : null} +
+
+
+
+ ); + } +} + +export const PolicyTable = injectI18n(PolicyTableUi); diff --git a/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/index.js b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/index.js new file mode 100644 index 0000000000000..c4aa32f1f7dc2 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/sections/policy_table/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { PolicyTable } from './components/policy_table'; diff --git a/x-pack/plugins/index_lifecycle_management/public/services/api.js b/x-pack/plugins/index_lifecycle_management/public/services/api.js new file mode 100644 index 0000000000000..a82cd3a0a7543 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/services/api.js @@ -0,0 +1,78 @@ +/* + * 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 chrome from 'ui/chrome'; + +let httpClient; +export const setHttpClient = (client) => { + httpClient = client; +}; +const getHttpClient = () => { + return httpClient; +}; +const apiPrefix = chrome.addBasePath('/api/index_lifecycle_management'); + +export async function loadNodes(httpClient = getHttpClient()) { + const response = await httpClient.get(`${apiPrefix}/nodes/list`); + return response.data; +} + +export async function loadNodeDetails(selectedNodeAttrs, httpClient = getHttpClient()) { + const response = await httpClient.get(`${apiPrefix}/nodes/${selectedNodeAttrs}/details`); + return response.data; +} + +export async function loadIndexTemplates(httpClient = getHttpClient()) { + const response = await httpClient.get(`${apiPrefix}/templates`); + return response.data; +} + +export async function loadIndexTemplate(templateName, httpClient = getHttpClient()) { + if (!templateName) { + return {}; + } + const response = await httpClient.get(`${apiPrefix}/template/${templateName}`); + return response.data; +} + +export async function loadPolicies(withIndices, httpClient = getHttpClient()) { + const response = await httpClient.get(`${apiPrefix}/policies${ withIndices ? '?withIndices=true' : ''}`); + return response.data; +} + +export async function deletePolicy(policyName, httpClient = getHttpClient()) { + const response = await httpClient.delete(`${apiPrefix}/policies/${encodeURIComponent(policyName)}`); + return response.data; +} + +export async function saveLifecycle(lifecycle, httpClient = getHttpClient()) { + const response = await httpClient.post(`${apiPrefix}/lifecycle`, { lifecycle }); + return response.data; +} + + +export async function getAffectedIndices(indexTemplateName, policyName, httpClient = getHttpClient()) { + const path = policyName + ? `${apiPrefix}/indices/affected/${indexTemplateName}/${encodeURIComponent(policyName)}` + : `${apiPrefix}/indices/affected/${indexTemplateName}`; + const response = await httpClient.get(path); + return response.data; +} +export const retryLifecycleForIndex = async (indexNames, httpClient = getHttpClient()) => { + const response = await httpClient.post(`${apiPrefix}/index/retry`, { indexNames }); + return response.data; +}; +export const removeLifecycleForIndex = async (indexNames, httpClient = getHttpClient()) => { + const response = await httpClient.post(`${apiPrefix}/index/remove`, { indexNames }); + return response.data; +}; +export const addLifecyclePolicyToIndex = async (body, httpClient = getHttpClient()) => { + const response = await httpClient.post(`${apiPrefix}/index/add`, body); + return response.data; +}; +export const addLifecyclePolicyToTemplate = async (body, httpClient = getHttpClient()) => { + const response = await httpClient.post(`${apiPrefix}/template`, body); + return response.data; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/services/api_errors.js b/x-pack/plugins/index_lifecycle_management/public/services/api_errors.js new file mode 100644 index 0000000000000..bacaf13405898 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/services/api_errors.js @@ -0,0 +1,42 @@ +/* + * 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 { fatalError, toastNotifications } from 'ui/notify'; + +function createToastConfig(error, errorTitle) { + // Expect an error in the shape provided by Angular's $http service. + if (error && error.data) { + const { error: errorString, statusCode, message } = error.data; + return { + title: errorTitle, + text: `${statusCode}: ${errorString}. ${message}`, + }; + } +} + +export function showApiWarning(error, errorTitle) { + const toastConfig = createToastConfig(error, errorTitle); + + if (toastConfig) { + return toastNotifications.addWarning(toastConfig); + } + + // This error isn't an HTTP error, so let the fatal error screen tell the user something + // unexpected happened. + return fatalError(error, errorTitle); +} + +export function showApiError(error, errorTitle) { + const toastConfig = createToastConfig(error, errorTitle); + + if (toastConfig) { + return toastNotifications.addDanger(toastConfig); + } + + // This error isn't an HTTP error, so let the fatal error screen tell the user something + // unexpected happened. + fatalError(error, errorTitle); +} diff --git a/x-pack/plugins/index_lifecycle_management/public/services/filter_items.js b/x-pack/plugins/index_lifecycle_management/public/services/filter_items.js new file mode 100644 index 0000000000000..6d2e3dae57f46 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/services/filter_items.js @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export const filterItems = (fields, filter = '', items = []) => { + const lowerFilter = filter.toLowerCase(); + return items.filter(item => { + const actualFields = fields || Object.keys(item); + const indexOfMatch = actualFields.findIndex(field => { + const normalizedField = String(item[field]).toLowerCase(); + return normalizedField.includes(lowerFilter); + }); + return indexOfMatch !== -1; + }); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/services/find_errors.js b/x-pack/plugins/index_lifecycle_management/public/services/find_errors.js new file mode 100644 index 0000000000000..7b77cf7a1301c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/services/find_errors.js @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export const findFirstError = (object, topLevel = true) => { + let firstError; + const keys = topLevel ? [ 'policyName', 'hot', 'warm', 'cold', 'delete'] : Object.keys(object); + for (const key of keys) { + const value = object[key]; + if (Array.isArray(value) && value.length > 0) { + firstError = key; + break; + } else if (value) { + firstError = findFirstError(value, false); + if (firstError) { + firstError = `${key}.${firstError}`; + break; + } + } + } + return firstError; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/services/flatten_panel_tree.js b/x-pack/plugins/index_lifecycle_management/public/services/flatten_panel_tree.js new file mode 100644 index 0000000000000..e060e22965cb3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/services/flatten_panel_tree.js @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export const flattenPanelTree = (tree, array = []) => { + array.push(tree); + + if (tree.items) { + tree.items.forEach(item => { + if (item.panel) { + flattenPanelTree(item.panel, array); + item.panel = item.panel.id; + } + }); + } + + return array; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/services/index.js b/x-pack/plugins/index_lifecycle_management/public/services/index.js new file mode 100644 index 0000000000000..86f51d8b4a4c1 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/services/index.js @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { filterItems } from './filter_items'; +export { sortTable } from './sort_table'; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/services/manage_angular_lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/services/manage_angular_lifecycle.js new file mode 100644 index 0000000000000..3813e632a0a73 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/services/manage_angular_lifecycle.js @@ -0,0 +1,23 @@ +/* + * 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 { unmountComponentAtNode } from 'react-dom'; + +export const manageAngularLifecycle = ($scope, $route, elem) => { + const lastRoute = $route.current; + + const deregister = $scope.$on('$locationChangeSuccess', () => { + const currentRoute = $route.current; + if (lastRoute.$$route.template === currentRoute.$$route.template) { + $route.current = lastRoute; + } + }); + + $scope.$on('$destroy', () => { + deregister && deregister(); + elem && unmountComponentAtNode(elem); + }); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/services/navigation.js b/x-pack/plugins/index_lifecycle_management/public/services/navigation.js new file mode 100644 index 0000000000000..655d9cf6608ff --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/services/navigation.js @@ -0,0 +1,23 @@ +/* + * 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. + */ + +let urlService; +import { BASE_PATH } from '../../common/constants'; +export const setUrlService = (aUrlService) => { + urlService = aUrlService; +}; +export const getUrlService = () => { + return urlService; +}; + + +export const goToPolicyList = () => { + urlService.change(`${BASE_PATH}policies`); +}; + +export const getPolicyPath = (policyName) => { + return encodeURI(`#${BASE_PATH}policies/edit/${encodeURIComponent(policyName)}`); +}; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/services/sort_table.js b/x-pack/plugins/index_lifecycle_management/public/services/sort_table.js new file mode 100644 index 0000000000000..6c390ecab07dc --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/services/sort_table.js @@ -0,0 +1,24 @@ +/* + * 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 { sortBy } from 'lodash'; + +const stringSort = (fieldName) => (item) => item[fieldName]; +const arraySort = (fieldName) => (item) => (item[fieldName] || []).length; + +const sorters = { + version: stringSort('version'), + name: stringSort('name'), + coveredIndices: arraySort('coveredIndices'), + modified_date: stringSort('modified_date') + +}; +export const sortTable = (array = [], sortField, isSortAscending) => { + const sorted = sortBy(array, sorters[sortField]); + return isSortAscending + ? sorted + : sorted.reverse(); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/actions/general.js b/x-pack/plugins/index_lifecycle_management/public/store/actions/general.js new file mode 100644 index 0000000000000..584488d4c2b42 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/actions/general.js @@ -0,0 +1,14 @@ +/* + * 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 { createAction } from 'redux-actions'; + +export const setBootstrapEnabled = createAction('SET_BOOTSTRAP_ENABLED'); +export const setIndexName = createAction('SET_INDEX_NAME'); +export const setAliasName = createAction('SET_ALIAS_NAME'); diff --git a/x-pack/plugins/index_lifecycle_management/public/store/actions/index.js b/x-pack/plugins/index_lifecycle_management/public/store/actions/index.js new file mode 100644 index 0000000000000..ea539578c885c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/actions/index.js @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export * from './nodes'; +export * from './policies'; +export * from './lifecycle'; +export * from './general'; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/actions/lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/store/actions/lifecycle.js new file mode 100644 index 0000000000000..9dcd1249de71c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/actions/lifecycle.js @@ -0,0 +1,38 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { toastNotifications } from 'ui/notify'; +import { showApiError } from '../../services/api_errors'; +import { saveLifecycle as saveLifecycleApi } from '../../services/api'; + + +export const saveLifecyclePolicy = (lifecycle, isNew) => async () => { + try { + await saveLifecycleApi(lifecycle); + } + catch (err) { + const title = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.saveErrorMessage', + { + defaultMessage: 'Error saving lifecycle policy {lifecycleName}', + values: { lifecycleName: lifecycle.name } + } + ); + showApiError(err, title); + return false; + } + const message = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.successfulSaveMessage', + { + defaultMessage: '{verb} lifecycle policy "{lifecycleName}"', + values: { verb: isNew ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createdMessage', { + defaultMessage: 'Created', + }) : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.updatedMessage', { + defaultMessage: 'Updated', + }), lifecycleName: lifecycle.name } + }, + ); + toastNotifications.addSuccess(message); + return true; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/actions/nodes.js b/x-pack/plugins/index_lifecycle_management/public/store/actions/nodes.js new file mode 100644 index 0000000000000..a2a526e44298e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/actions/nodes.js @@ -0,0 +1,61 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { createAction } from 'redux-actions'; +import { showApiError } from '../../services/api_errors'; +import { loadNodes, loadNodeDetails } from '../../services/api'; +import { SET_SELECTED_NODE_ATTRS } from '../constants'; + +export const setSelectedNodeAttrs = createAction(SET_SELECTED_NODE_ATTRS); +export const setSelectedPrimaryShardCount = createAction( + 'SET_SELECTED_PRIMARY_SHARED_COUNT' +); +export const setSelectedReplicaCount = createAction( + 'SET_SELECTED_REPLICA_COUNT' +); +export const fetchedNodes = createAction('FETCHED_NODES'); +let fetchingNodes = false; +export const fetchNodes = () => async dispatch => { + try { + if (!fetchingNodes) { + fetchingNodes = true; + const nodes = await loadNodes(); + dispatch(fetchedNodes(nodes)); + } + } catch (err) { + const title = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.nodeInfoErrorMessage', + { + defaultMessage: 'Error loading node attribute information', + }, + ); + showApiError(err, title); + } finally { + fetchingNodes = false; + } +}; + +export const fetchedNodeDetails = createAction( + 'FETCHED_NODE_DETAILS', + (selectedNodeAttrs, details) => ({ + selectedNodeAttrs, + details, + }) +); +export const fetchNodeDetails = selectedNodeAttrs => async dispatch => { + let details; + try { + details = await loadNodeDetails(selectedNodeAttrs); + } catch (err) { + const title = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.nodeDetailErrorMessage', + { + defaultMessage: 'Error loading node attribute details', + }, + ); + showApiError(err, title); + return false; + } + dispatch(fetchedNodeDetails(selectedNodeAttrs, details)); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/actions/policies.js b/x-pack/plugins/index_lifecycle_management/public/store/actions/policies.js new file mode 100644 index 0000000000000..fb2384855d92c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/actions/policies.js @@ -0,0 +1,47 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { showApiError } from '../../services/api_errors'; +import { createAction } from 'redux-actions'; +import { loadPolicies } from '../../services/api'; +import { SET_PHASE_DATA } from '../constants'; +export const fetchedPolicies = createAction('FETCHED_POLICIES'); +export const setSelectedPolicy = createAction('SET_SELECTED_POLICY'); +export const unsetSelectedPolicy = createAction('UNSET_SELECTED_POLICY'); +export const setSelectedPolicyName = createAction('SET_SELECTED_POLICY_NAME'); +export const setSaveAsNewPolicy = createAction('SET_SAVE_AS_NEW_POLICY'); +export const policySortChanged = createAction('POLICY_SORT_CHANGED'); +export const policyPageSizeChanged = createAction('POLICY_PAGE_SIZE_CHANGED'); +export const policyPageChanged = createAction('POLICY_PAGE_CHANGED'); +export const policySortDirectionChanged = createAction('POLICY_SORT_DIRECTION_CHANGED'); +export const policyFilterChanged = createAction('POLICY_FILTER_CHANGED'); + +export const fetchPolicies = (withIndices, callback) => async dispatch => { + let policies; + try { + policies = await loadPolicies(withIndices); + } + catch (err) { + const title = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.loadPolicyErrorMessage', + { + defaultMessage: 'Error loading policies', + }, + ); + showApiError(err, title); + return false; + } + + dispatch(fetchedPolicies(policies)); + if (policies.length === 0) { + dispatch(setSelectedPolicy()); + } + callback && callback(); + return policies; +}; + + +export const setPhaseData = createAction(SET_PHASE_DATA, (phase, key, value) => ({ phase, key, value })); diff --git a/x-pack/plugins/index_lifecycle_management/public/store/constants.js b/x-pack/plugins/index_lifecycle_management/public/store/constants.js new file mode 100644 index 0000000000000..a49ebd6ee6be1 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/constants.js @@ -0,0 +1,94 @@ +/* + * 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. + */ + +export const SET_PHASE_DATA = 'SET_PHASE_DATA'; +export const SET_SELECTED_NODE_ATTRS = 'SET_SELECTED_NODE_ATTRS'; +export const PHASE_HOT = 'hot'; +export const PHASE_WARM = 'warm'; +export const PHASE_COLD = 'cold'; +export const PHASE_DELETE = 'delete'; + +export const PHASE_ENABLED = 'phaseEnabled'; + +export const MAX_SIZE_TYPE_DOCUMENT = 'd'; + +export const PHASE_ROLLOVER_ENABLED = 'rolloverEnabled'; +export const WARM_PHASE_ON_ROLLOVER = 'warmPhaseOnRollover'; +export const PHASE_ROLLOVER_ALIAS = 'selectedAlias'; +export const PHASE_ROLLOVER_MAX_AGE = 'selectedMaxAge'; +export const PHASE_ROLLOVER_MAX_AGE_UNITS = 'selectedMaxAgeUnits'; +export const PHASE_ROLLOVER_MAX_SIZE_STORED = 'selectedMaxSizeStored'; +export const PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS = 'selectedMaxSizeStoredUnits'; +export const PHASE_ROLLOVER_MINIMUM_AGE = 'selectedMinimumAge'; +export const PHASE_ROLLOVER_MINIMUM_AGE_UNITS = 'selectedMinimumAgeUnits'; + +export const PHASE_FORCE_MERGE_SEGMENTS = 'selectedForceMergeSegments'; +export const PHASE_FORCE_MERGE_ENABLED = 'forceMergeEnabled'; + +export const PHASE_SHRINK_ENABLED = 'shrinkEnabled'; + +export const PHASE_NODE_ATTRS = 'selectedNodeAttrs'; +export const PHASE_PRIMARY_SHARD_COUNT = 'selectedPrimaryShardCount'; +export const PHASE_REPLICA_COUNT = 'selectedReplicaCount'; + +export const PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE = [ + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_FORCE_MERGE_SEGMENTS, + PHASE_PRIMARY_SHARD_COUNT, + PHASE_REPLICA_COUNT, +]; +export const PHASE_ATTRIBUTES_THAT_ARE_NUMBERS = [ + ...PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE, + PHASE_ROLLOVER_MAX_AGE, + PHASE_ROLLOVER_MAX_SIZE_STORED, +]; + +export const STRUCTURE_INDEX_TEMPLATE = 'indexTemplate'; +export const STRUCTURE_TEMPLATE_SELECTION = 'templateSelection'; +export const STRUCTURE_TEMPLATE_NAME = 'templateName'; +export const STRUCTURE_CONFIGURATION = 'configuration'; +export const STRUCTURE_NODE_ATTRS = 'node_attrs'; +export const STRUCTURE_PRIMARY_NODES = 'primary_nodes'; +export const STRUCTURE_REPLICAS = 'replicas'; + +export const STRUCTURE_POLICY_CONFIGURATION = 'policyConfiguration'; + +export const STRUCTURE_REVIEW = 'review'; +export const STRUCTURE_POLICY_NAME = 'policyName'; +export const STRUCTURE_INDEX_NAME = 'indexName'; +export const STRUCTURE_ALIAS_NAME = 'aliasName'; + +export const ERROR_STRUCTURE = { + [PHASE_HOT]: { + [PHASE_ROLLOVER_ALIAS]: [], + [PHASE_ROLLOVER_MAX_AGE]: [], + [PHASE_ROLLOVER_MAX_AGE_UNITS]: [], + [PHASE_ROLLOVER_MAX_SIZE_STORED]: [], + [PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]: [], + }, + [PHASE_WARM]: { + [PHASE_ROLLOVER_ALIAS]: [], + [PHASE_ROLLOVER_MINIMUM_AGE]: [], + [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: [], + [PHASE_NODE_ATTRS]: [], + [PHASE_PRIMARY_SHARD_COUNT]: [], + [PHASE_REPLICA_COUNT]: [], + [PHASE_FORCE_MERGE_SEGMENTS]: [], + }, + [PHASE_COLD]: { + [PHASE_ROLLOVER_ALIAS]: [], + [PHASE_ROLLOVER_MINIMUM_AGE]: [], + [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: [], + [PHASE_NODE_ATTRS]: [], + [PHASE_REPLICA_COUNT]: [], + }, + [PHASE_DELETE]: { + [PHASE_ROLLOVER_ALIAS]: [], + [PHASE_ROLLOVER_MINIMUM_AGE]: [], + [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: [], + }, + [STRUCTURE_POLICY_NAME]: [], +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js b/x-pack/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js new file mode 100644 index 0000000000000..dcc2c6156f119 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js @@ -0,0 +1,22 @@ +/* + * 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 { + PHASE_ENABLED, + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_NODE_ATTRS, + PHASE_REPLICA_COUNT, + PHASE_ROLLOVER_MINIMUM_AGE_UNITS, + PHASE_ROLLOVER_ALIAS, +} from '../constants'; + +export const defaultColdPhase = { + [PHASE_ENABLED]: false, + [PHASE_ROLLOVER_ALIAS]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', + [PHASE_NODE_ATTRS]: '', + [PHASE_REPLICA_COUNT]: '' +}; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js b/x-pack/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js new file mode 100644 index 0000000000000..e5326615e536a --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js @@ -0,0 +1,20 @@ +/* + * 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 { + PHASE_ENABLED, + PHASE_ROLLOVER_ENABLED, + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_ROLLOVER_MINIMUM_AGE_UNITS, + PHASE_ROLLOVER_ALIAS, +} from '../constants'; + +export const defaultDeletePhase = { + [PHASE_ENABLED]: false, + [PHASE_ROLLOVER_ENABLED]: false, + [PHASE_ROLLOVER_ALIAS]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd' +}; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/store/defaults/hot_phase.js b/x-pack/plugins/index_lifecycle_management/public/store/defaults/hot_phase.js new file mode 100644 index 0000000000000..107a803891f6a --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/defaults/hot_phase.js @@ -0,0 +1,24 @@ +/* + * 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 { + PHASE_ENABLED, + PHASE_ROLLOVER_ENABLED, + PHASE_ROLLOVER_MAX_AGE, + PHASE_ROLLOVER_MAX_AGE_UNITS, + PHASE_ROLLOVER_MAX_SIZE_STORED, + PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, + PHASE_ROLLOVER_ALIAS, +} from '../constants'; + +export const defaultHotPhase = { + [PHASE_ENABLED]: true, + [PHASE_ROLLOVER_ENABLED]: true, + [PHASE_ROLLOVER_ALIAS]: '', + [PHASE_ROLLOVER_MAX_AGE]: '', + [PHASE_ROLLOVER_MAX_AGE_UNITS]: 'd', + [PHASE_ROLLOVER_MAX_SIZE_STORED]: '', + [PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]: 'gb', +}; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/store/defaults/index.js b/x-pack/plugins/index_lifecycle_management/public/store/defaults/index.js new file mode 100644 index 0000000000000..a92f98fa8e022 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/defaults/index.js @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export { defaultDeletePhase } from './delete_phase'; +export { defaultColdPhase } from './cold_phase'; +export { defaultHotPhase } from './hot_phase'; +export { defaultWarmPhase } from './warm_phase'; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js b/x-pack/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js new file mode 100644 index 0000000000000..c53c782d410a3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js @@ -0,0 +1,32 @@ +/* + * 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 { + PHASE_ENABLED, + PHASE_FORCE_MERGE_SEGMENTS, + PHASE_FORCE_MERGE_ENABLED, + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_NODE_ATTRS, + PHASE_PRIMARY_SHARD_COUNT, + PHASE_REPLICA_COUNT, + PHASE_ROLLOVER_MINIMUM_AGE_UNITS, + PHASE_ROLLOVER_ALIAS, + PHASE_SHRINK_ENABLED, + WARM_PHASE_ON_ROLLOVER +} from '../constants'; + +export const defaultWarmPhase = { + [PHASE_ENABLED]: false, + [PHASE_ROLLOVER_ALIAS]: '', + [PHASE_FORCE_MERGE_SEGMENTS]: '', + [PHASE_FORCE_MERGE_ENABLED]: false, + [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', + [PHASE_NODE_ATTRS]: '', + [PHASE_SHRINK_ENABLED]: false, + [PHASE_PRIMARY_SHARD_COUNT]: '', + [PHASE_REPLICA_COUNT]: '', + [WARM_PHASE_ON_ROLLOVER]: false +}; \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/public/store/index.js b/x-pack/plugins/index_lifecycle_management/public/store/index.js new file mode 100644 index 0000000000000..808eb489bf913 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { indexLifecycleManagementStore } from './store'; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/reducers/general.js b/x-pack/plugins/index_lifecycle_management/public/store/reducers/general.js new file mode 100644 index 0000000000000..abb56f5cdae2f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/reducers/general.js @@ -0,0 +1,38 @@ +/* + * 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 { handleActions } from 'redux-actions'; +import { setIndexName, setAliasName, setBootstrapEnabled } from '../actions/general'; + +const defaultState = { + bootstrapEnabled: false, + indexName: '', + aliasName: '', +}; + +export const general = handleActions({ + [setIndexName](state, { payload: indexName }) { + return { + ...state, + indexName, + }; + }, + [setAliasName](state, { payload: aliasName }) { + return { + ...state, + aliasName, + }; + }, + [setBootstrapEnabled](state, { payload: bootstrapEnabled }) { + return { + ...state, + bootstrapEnabled, + }; + } +}, defaultState); diff --git a/x-pack/plugins/index_lifecycle_management/public/store/reducers/index.js b/x-pack/plugins/index_lifecycle_management/public/store/reducers/index.js new file mode 100644 index 0000000000000..d212855db6750 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/reducers/index.js @@ -0,0 +1,19 @@ +/* + * 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 { combineReducers } from 'redux'; +import { nodes } from './nodes'; +import { policies } from './policies'; +import { general } from './general'; + +export const indexLifecycleManagement = combineReducers({ + nodes, + policies, + general, +}); diff --git a/x-pack/plugins/index_lifecycle_management/public/store/reducers/nodes.js b/x-pack/plugins/index_lifecycle_management/public/store/reducers/nodes.js new file mode 100644 index 0000000000000..2a4286a72d22d --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/reducers/nodes.js @@ -0,0 +1,80 @@ +/* + * 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 { handleActions } from 'redux-actions'; +import { + fetchedNodes, + setSelectedNodeAttrs, + setSelectedPrimaryShardCount, + setSelectedReplicaCount, + fetchedNodeDetails +} from '../actions/nodes'; + +const defaultState = { + isLoading: false, + selectedNodeAttrs: '', + selectedPrimaryShardCount: 1, + selectedReplicaCount: 1, + nodes: undefined, + details: {}, +}; + +export const nodes = handleActions( + { + [fetchedNodes](state, { payload: nodes }) { + return { + ...state, + isLoading: false, + nodes + }; + }, + [fetchedNodeDetails](state, { payload }) { + const { selectedNodeAttrs, details } = payload; + return { + ...state, + details: { + ...state.details, + [selectedNodeAttrs]: details, + } + }; + }, + [setSelectedNodeAttrs](state, { payload: selectedNodeAttrs }) { + return { + ...state, + selectedNodeAttrs + }; + }, + [setSelectedPrimaryShardCount](state, { payload }) { + let selectedPrimaryShardCount = parseInt(payload); + if (isNaN(selectedPrimaryShardCount)) { + selectedPrimaryShardCount = ''; + } + return { + ...state, + selectedPrimaryShardCount + }; + }, + [setSelectedReplicaCount](state, { payload }) { + let selectedReplicaCount; + if (payload != null) { + selectedReplicaCount = parseInt(payload); + if (isNaN(selectedReplicaCount)) { + selectedReplicaCount = ''; + } + } else { + // default value for Elasticsearch + selectedReplicaCount = 1; + } + + + return { + ...state, + selectedReplicaCount + }; + } + }, + defaultState +); diff --git a/x-pack/plugins/index_lifecycle_management/public/store/reducers/policies.js b/x-pack/plugins/index_lifecycle_management/public/store/reducers/policies.js new file mode 100644 index 0000000000000..27b5304fec1fa --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/reducers/policies.js @@ -0,0 +1,176 @@ +/* + * 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 { handleActions } from 'redux-actions'; +import { + fetchedPolicies, + setSelectedPolicy, + unsetSelectedPolicy, + setSelectedPolicyName, + setSaveAsNewPolicy, + setPhaseData, + policyFilterChanged, + policyPageChanged, + policyPageSizeChanged, + policySortChanged, +} from '../actions'; +import { policyFromES } from '../selectors'; +import { + PHASE_HOT, + PHASE_WARM, + PHASE_COLD, + PHASE_DELETE, + PHASE_ATTRIBUTES_THAT_ARE_NUMBERS, +} from '../constants'; + +import { + defaultColdPhase, + defaultDeletePhase, + defaultHotPhase, + defaultWarmPhase, +} from '../defaults'; +export const defaultPolicy = { + name: '', + saveAsNew: true, + isNew: true, + phases: { + [PHASE_HOT]: defaultHotPhase, + [PHASE_WARM]: defaultWarmPhase, + [PHASE_COLD]: defaultColdPhase, + [PHASE_DELETE]: defaultDeletePhase + } +}; + +const defaultState = { + isLoading: false, + isLoaded: false, + originalPolicyName: undefined, + selectedPolicySet: false, + selectedPolicy: defaultPolicy, + policies: [], + sort: { + sortField: 'name', + isSortAscending: true + }, + pageSize: 10, + currentPage: 0, + filter: '' +}; + +export const policies = handleActions( + { + [fetchedPolicies](state, { payload: policies }) { + return { + ...state, + isLoading: false, + isLoaded: true, + policies + }; + }, + [setSelectedPolicy](state, { payload: selectedPolicy }) { + if (!selectedPolicy) { + return { + ...state, + selectedPolicy: defaultPolicy, + selectedPolicySet: true, + }; + } + + return { + ...state, + originalPolicyName: selectedPolicy.name, + selectedPolicySet: true, + selectedPolicy: { + ...defaultPolicy, + ...policyFromES(selectedPolicy) + } + }; + }, + [unsetSelectedPolicy]() { + return defaultState; + }, + [setSelectedPolicyName](state, { payload: name }) { + return { + ...state, + selectedPolicy: { + ...state.selectedPolicy, + name + } + }; + }, + [setSaveAsNewPolicy](state, { payload: saveAsNew }) { + return { + ...state, + selectedPolicy: { + ...state.selectedPolicy, + saveAsNew + } + }; + }, + [setPhaseData](state, { payload }) { + const { phase, key } = payload; + + let value = payload.value; + if (PHASE_ATTRIBUTES_THAT_ARE_NUMBERS.includes(key)) { + value = parseInt(value); + if (isNaN(value)) { + value = ''; + } + } + + return { + ...state, + selectedPolicy: { + ...state.selectedPolicy, + phases: { + ...state.selectedPolicy.phases, + [phase]: { + ...state.selectedPolicy.phases[phase], + [key]: value + } + } + } + }; + }, + [policyFilterChanged](state, action) { + const { filter } = action.payload; + return { + ...state, + filter, + currentPage: 0 + }; + }, + [policySortChanged](state, action) { + const { sortField, isSortAscending } = action.payload; + + return { + ...state, + sort: { + sortField, + isSortAscending, + } + }; + }, + [policyPageChanged](state, action) { + const { pageNumber } = action.payload; + return { + ...state, + currentPage: pageNumber, + }; + }, + [policyPageSizeChanged](state, action) { + const { pageSize } = action.payload; + return { + ...state, + pageSize + }; + } + }, + defaultState +); diff --git a/x-pack/plugins/index_lifecycle_management/public/store/selectors/general.js b/x-pack/plugins/index_lifecycle_management/public/store/selectors/general.js new file mode 100644 index 0000000000000..41459d1bbb2c8 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/selectors/general.js @@ -0,0 +1,12 @@ +/* + * 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. + */ + + + + +export const getBootstrapEnabled = state => state.general.bootstrapEnabled; +export const getIndexName = state => state.general.indexName; +export const getAliasName = state => state.general.aliasName; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/selectors/index.js b/x-pack/plugins/index_lifecycle_management/public/store/selectors/index.js new file mode 100644 index 0000000000000..ea539578c885c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/selectors/index.js @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export * from './nodes'; +export * from './policies'; +export * from './lifecycle'; +export * from './general'; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js new file mode 100644 index 0000000000000..632048748b07c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js @@ -0,0 +1,229 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + PHASE_HOT, + PHASE_WARM, + PHASE_COLD, + PHASE_DELETE, + PHASE_ENABLED, + PHASE_ROLLOVER_ENABLED, + PHASE_ROLLOVER_MAX_AGE, + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_ROLLOVER_MAX_SIZE_STORED, + STRUCTURE_POLICY_NAME, + ERROR_STRUCTURE, + PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE, + PHASE_PRIMARY_SHARD_COUNT, + PHASE_SHRINK_ENABLED, + PHASE_FORCE_MERGE_ENABLED, + PHASE_FORCE_MERGE_SEGMENTS, + PHASE_REPLICA_COUNT, + WARM_PHASE_ON_ROLLOVER +} from '../constants'; +import { + getPhase, + getPhases, + phaseToES, + getSelectedPolicyName, + isNumber, + getSaveAsNewPolicy, + getSelectedOriginalPolicyName, + getPolicies +} from '.'; +export const numberRequiredMessage = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.numberRequiredError', { + defaultMessage: 'A number is required.' +}); +export const positiveNumberRequiredMessage = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError', { + defaultMessage: 'Only positive numbers are allowed.' +}); +export const maximumAgeRequiredMessage = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.maximumAgeMissingError', { + defaultMessage: 'A maximum age is required.' +}); +export const maximumSizeRequiredMessage = + i18n.translate('xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError', { + defaultMessage: 'A maximum index size is required.' + }); +export const positiveNumbersAboveZeroErrorMessage = + i18n.translate('xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError', { + defaultMessage: 'Only numbers above 0 are allowed.' + }); +export const validatePhase = (type, phase, errors) => { + const phaseErrors = {}; + + if (!phase[PHASE_ENABLED]) { + return; + } + + for (const numberedAttribute of PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE) { + if (phase.hasOwnProperty(numberedAttribute)) { + // If WARM_PHASE_ON_ROLLOVER there is no need to validate this + if (numberedAttribute === PHASE_ROLLOVER_MINIMUM_AGE && phase[WARM_PHASE_ON_ROLLOVER]) { + continue; + } + // If shrink is disabled, there is no need to validate this + if (numberedAttribute === PHASE_PRIMARY_SHARD_COUNT && !phase[PHASE_SHRINK_ENABLED]) { + continue; + } + // If forcemerge is disabled, there is no need to validate this + if (numberedAttribute === PHASE_FORCE_MERGE_SEGMENTS && !phase[PHASE_FORCE_MERGE_ENABLED]) { + continue; + } + // PHASE_REPLICA_COUNT is optional + if (numberedAttribute === PHASE_REPLICA_COUNT && !phase[numberedAttribute]) { + continue; + } + if (!isNumber(phase[numberedAttribute])) { + phaseErrors[numberedAttribute] = [numberRequiredMessage]; + } + else if (phase[numberedAttribute] < 0) { + phaseErrors[numberedAttribute] = [positiveNumberRequiredMessage]; + } + else if ((numberedAttribute === PHASE_ROLLOVER_MINIMUM_AGE || + numberedAttribute === PHASE_PRIMARY_SHARD_COUNT) && phase[numberedAttribute] < 1) { + phaseErrors[numberedAttribute] = [positiveNumbersAboveZeroErrorMessage]; + } + } + } + if (phase[PHASE_ROLLOVER_ENABLED]) { + if ( + !isNumber(phase[PHASE_ROLLOVER_MAX_AGE]) && + !isNumber(phase[PHASE_ROLLOVER_MAX_SIZE_STORED]) + ) { + phaseErrors[PHASE_ROLLOVER_MAX_AGE] = [ + maximumAgeRequiredMessage + ]; + phaseErrors[PHASE_ROLLOVER_MAX_SIZE_STORED] = [ + maximumSizeRequiredMessage + ]; + } + } + if (phase[PHASE_SHRINK_ENABLED]) { + if (!isNumber(phase[PHASE_PRIMARY_SHARD_COUNT])) { + phaseErrors[PHASE_PRIMARY_SHARD_COUNT] = [numberRequiredMessage]; + } + else if (phase[PHASE_PRIMARY_SHARD_COUNT] < 1) { + phaseErrors[PHASE_PRIMARY_SHARD_COUNT] = [positiveNumbersAboveZeroErrorMessage]; + } + } + + if (phase[PHASE_FORCE_MERGE_ENABLED]) { + if (!isNumber(phase[PHASE_FORCE_MERGE_SEGMENTS])) { + phaseErrors[PHASE_FORCE_MERGE_SEGMENTS] = [numberRequiredMessage]; + } + else if (phase[PHASE_FORCE_MERGE_SEGMENTS] < 1) { + phaseErrors[PHASE_FORCE_MERGE_SEGMENTS] = [ + positiveNumbersAboveZeroErrorMessage + ]; + } + } + errors[type] = { + ...errors[type], + ...phaseErrors + }; +}; + +export const policyNameRequiredMessage = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', { + defaultMessage: 'A policy name is required.' +}); +export const policyNameStartsWithUnderscoreErrorMessage = + i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', { + defaultMessage: 'A policy name cannot start with an underscore.' + }); +export const policyNameContainsCommaErrorMessage = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError', { + defaultMessage: 'A policy name cannot include a comma.' +}); +export const policyNameContainsSpaceErrorMessage = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError', { + defaultMessage: 'A policy name cannot include a space.' +}); +export const policyNameTooLongErrorMessage = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', { + defaultMessage: 'A policy name cannot be longer than 255 bytes.' +}); +export const policyNameMustBeDifferentErrorMessage = + i18n.translate('xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', { + defaultMessage: 'The policy name must be different.' + }); +export const policyNameAlreadyUsedErrorMessage = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', { + defaultMessage: 'That policy name is already used.' +}); +export const validateLifecycle = state => { + // This method of deep copy does not always work but it should be fine here + const errors = JSON.parse(JSON.stringify(ERROR_STRUCTURE)); + const policyName = getSelectedPolicyName(state); + if (!policyName) { + errors[STRUCTURE_POLICY_NAME].push(policyNameRequiredMessage); + } else { + if (policyName.startsWith('_')) { + errors[STRUCTURE_POLICY_NAME].push(policyNameStartsWithUnderscoreErrorMessage); + } + if (policyName.includes(',')) { + errors[STRUCTURE_POLICY_NAME].push(policyNameContainsCommaErrorMessage); + } + if (policyName.includes(' ')) { + errors[STRUCTURE_POLICY_NAME].push(policyNameContainsSpaceErrorMessage); + } + if (TextEncoder && new TextEncoder('utf-8').encode(policyName).length > 255) { + errors[STRUCTURE_POLICY_NAME].push(policyNameTooLongErrorMessage); + } + } + + if (getSaveAsNewPolicy(state) && getSelectedOriginalPolicyName(state) === getSelectedPolicyName(state)) { + errors[STRUCTURE_POLICY_NAME].push(policyNameMustBeDifferentErrorMessage); + } else { + const policyNames = getPolicies(state).map(policy => policy.name); + if (policyNames.includes(getSelectedPolicyName(state))) { + errors[STRUCTURE_POLICY_NAME].push(policyNameAlreadyUsedErrorMessage); + } + } + + + const hotPhase = getPhase(state, PHASE_HOT); + const warmPhase = getPhase(state, PHASE_WARM); + const coldPhase = getPhase(state, PHASE_COLD); + const deletePhase = getPhase(state, PHASE_DELETE); + + validatePhase(PHASE_HOT, hotPhase, errors); + validatePhase(PHASE_WARM, warmPhase, errors); + validatePhase(PHASE_COLD, coldPhase, errors); + validatePhase(PHASE_DELETE, deletePhase, errors); + return errors; +}; + +export const getLifecycle = state => { + const phases = Object.entries(getPhases(state)).reduce( + (accum, [phaseName, phase]) => { + // Hot is ALWAYS enabled + if (phaseName === PHASE_HOT) { + phase[PHASE_ENABLED] = true; + } + + if (phase[PHASE_ENABLED]) { + accum[phaseName] = phaseToES(state, phase); + + // These seem to be constants + // TODO: verify this assumption + if (phaseName === PHASE_HOT) { + accum[phaseName].min_age = '0s'; + } + + if (phaseName === PHASE_DELETE) { + accum[phaseName].actions = { + ...accum[phaseName].actions, + delete: {} + }; + } + } + return accum; + }, + {} + ); + + return { + name: getSelectedPolicyName(state), + //type, TODO: figure this out (jsut store it and not let the user change it?) + phases + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/selectors/nodes.js b/x-pack/plugins/index_lifecycle_management/public/store/selectors/nodes.js new file mode 100644 index 0000000000000..82774559fe275 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/selectors/nodes.js @@ -0,0 +1,46 @@ +/* + * 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 { createSelector } from 'reselect'; + +export const getNodes = state => state.nodes.nodes; +export const getNodeOptions = createSelector( + [state => getNodes(state)], + nodes => { + if (!nodes) { + return null; + } + + const options = Object.keys(nodes).map(attrs => ({ + text: `${attrs} (${nodes[attrs].length})`, + value: attrs, + })); + + options.sort((a, b) => a.value.localeCompare(b.value)); + if (options.length) { + return [{ text: 'Default allocation (don\'t use attributes)', value: '' }, ...options]; + } else { + return options; + } + } +); + +export const getSelectedPrimaryShardCount = state => + state.nodes.selectedPrimaryShardCount; +export const getSelectedReplicaCount = state => + state.nodes.selectedReplicaCount !== undefined ? state.nodes.selectedReplicaCount : 1; +export const getSelectedNodeAttrs = state => state.nodes.selectedNodeAttrs; +export const getNodesFromSelectedNodeAttrs = state => { + const nodes = getNodes(state)[getSelectedNodeAttrs(state)]; + if (nodes) { + return nodes.length; + } + return null; +}; + +export const getNodeDetails = (state, selectedNodeAttrs) => { + return state.nodes.details[selectedNodeAttrs]; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/selectors/policies.js b/x-pack/plugins/index_lifecycle_management/public/store/selectors/policies.js new file mode 100644 index 0000000000000..57065812f4b6f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/selectors/policies.js @@ -0,0 +1,273 @@ +/* + * 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 { createSelector } from 'reselect'; +import { Pager } from '@elastic/eui'; +import { + defaultColdPhase, + defaultDeletePhase, + defaultHotPhase, + defaultWarmPhase, +} from '../defaults'; +import { + PHASE_HOT, + PHASE_WARM, + PHASE_COLD, + PHASE_DELETE, + PHASE_ROLLOVER_MINIMUM_AGE, + PHASE_ROLLOVER_MINIMUM_AGE_UNITS, + PHASE_ROLLOVER_ENABLED, + PHASE_ROLLOVER_MAX_AGE, + PHASE_ROLLOVER_MAX_AGE_UNITS, + PHASE_ROLLOVER_MAX_SIZE_STORED, + PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, + PHASE_NODE_ATTRS, + PHASE_FORCE_MERGE_ENABLED, + PHASE_FORCE_MERGE_SEGMENTS, + PHASE_PRIMARY_SHARD_COUNT, + PHASE_REPLICA_COUNT, + PHASE_ENABLED, + PHASE_ATTRIBUTES_THAT_ARE_NUMBERS, + MAX_SIZE_TYPE_DOCUMENT, + WARM_PHASE_ON_ROLLOVER, + PHASE_SHRINK_ENABLED +} from '../constants'; +import { filterItems, sortTable } from '../../services'; + + +export const getPolicies = state => state.policies.policies; +export const getIsNewPolicy = state => state.policies.selectedPolicy.isNew; +export const getSelectedPolicy = state => state.policies.selectedPolicy; +export const getIsSelectedPolicySet = state => state.policies.selectedPolicySet; +export const getSelectedOriginalPolicyName = state => state.policies.originalPolicyName; +export const getPolicyFilter = (state) => state.policies.filter; +export const getPolicySort = (state) => state.policies.sort; +export const getPolicyCurrentPage = (state) => state.policies.currentPage; +export const getPolicyPageSize = (state) => state.policies.pageSize; +export const isPolicyListLoaded = (state) => state.policies.isLoaded; + +const getFilteredPolicies = createSelector( + getPolicies, + getPolicyFilter, + (policies, filter) => { + return filterItems(['name'], filter, policies); + } +); +export const getTotalPolicies = createSelector( + getFilteredPolicies, + (filteredPolicies) => { + return filteredPolicies.length; + } +); +export const getPolicyPager = createSelector( + getPolicyCurrentPage, + getPolicyPageSize, + getTotalPolicies, + (currentPage, pageSize, totalPolicies) => { + return new Pager(totalPolicies, pageSize, currentPage); + } +); +export const getPageOfPolicies = createSelector( + getFilteredPolicies, + getPolicySort, + getPolicyPager, + (filteredPolicies, sort, pager) => { + const sortedPolicies = sortTable(filteredPolicies, sort.sortField, sort.isSortAscending); + const { firstItemIndex, lastItemIndex } = pager; + const pagedPolicies = sortedPolicies.slice(firstItemIndex, lastItemIndex + 1); + return pagedPolicies; + } +); +export const getSaveAsNewPolicy = state => + state.policies.selectedPolicy.saveAsNew; + +export const getSelectedPolicyName = state => { + if (!getSaveAsNewPolicy(state)) { + return getSelectedOriginalPolicyName(state); + } + return state.policies.selectedPolicy.name; +}; + +export const getPhases = state => state.policies.selectedPolicy.phases; +export const getPhase = (state, phase) => + getPhases(state)[phase]; +export const getPhaseData = (state, phase, key) => { + if (PHASE_ATTRIBUTES_THAT_ARE_NUMBERS.includes(key)) { + return parseInt(getPhase(state, phase)[key]); + } + return getPhase(state, phase)[key]; +}; + +export const splitSizeAndUnits = field => { + let size; + let units; + + const result = /(\d+)(\w+)/.exec(field); + if (result) { + size = parseInt(result[1]) || 0; + units = result[2]; + } + + return { + size, + units + }; +}; + +export const isNumber = value => typeof value === 'number'; + +export const phaseFromES = (phase, phaseName, defaultPolicy) => { + const policy = { ...defaultPolicy }; + + if (!phase) { + return policy; + } + + policy[PHASE_ENABLED] = true; + policy[PHASE_ROLLOVER_ENABLED] = false; + + if (phase.min_age) { + if (phaseName === PHASE_WARM && phase.min_age === '0ms') { + policy[WARM_PHASE_ON_ROLLOVER] = true; + } else { + const { size: minAge, units: minAgeUnits } = splitSizeAndUnits( + phase.min_age + ); + policy[PHASE_ROLLOVER_MINIMUM_AGE] = minAge; + policy[PHASE_ROLLOVER_MINIMUM_AGE_UNITS] = minAgeUnits; + } + } + if (phaseName === PHASE_WARM) { + policy[PHASE_SHRINK_ENABLED] = !!(phase.actions && phase.actions.shrink); + } + if (phase.actions) { + const actions = phase.actions; + + if (actions.rollover) { + const rollover = actions.rollover; + policy[PHASE_ROLLOVER_ENABLED] = true; + if (rollover.max_age) { + const { size: maxAge, units: maxAgeUnits } = splitSizeAndUnits( + rollover.max_age + ); + policy[PHASE_ROLLOVER_MAX_AGE] = maxAge; + policy[PHASE_ROLLOVER_MAX_AGE_UNITS] = maxAgeUnits; + } + if (rollover.max_size) { + const { size: maxSize, units: maxSizeUnits } = splitSizeAndUnits( + rollover.max_size + ); + policy[PHASE_ROLLOVER_MAX_SIZE_STORED] = maxSize; + policy[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS] = maxSizeUnits; + } + if (rollover.max_docs) { + policy[PHASE_ROLLOVER_MAX_SIZE_STORED] = rollover.max_docs; + policy[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS] = MAX_SIZE_TYPE_DOCUMENT; + } + } + + if (actions.allocate) { + const allocate = actions.allocate; + if (allocate.require) { + Object.entries(allocate.require).forEach((entry) => { + policy[PHASE_NODE_ATTRS] = entry.join(':'); + }); + // checking for null or undefined here + if (allocate.number_of_replicas != null) { + policy[PHASE_REPLICA_COUNT] = allocate.number_of_replicas; + } + } + } + + if (actions.forcemerge) { + const forcemerge = actions.forcemerge; + policy[PHASE_FORCE_MERGE_ENABLED] = true; + policy[PHASE_FORCE_MERGE_SEGMENTS] = forcemerge.max_num_segments; + } + + if (actions.shrink) { + policy[PHASE_PRIMARY_SHARD_COUNT] = actions.shrink.number_of_shards; + } + } + return policy; +}; + +export const policyFromES = (policy) => { + const { name, policy: { phases } } = policy; + return { + name, + phases: { + [PHASE_HOT]: phaseFromES(phases[PHASE_HOT], PHASE_HOT, defaultHotPhase), + [PHASE_WARM]: phaseFromES(phases[PHASE_WARM], PHASE_WARM, defaultWarmPhase), + [PHASE_COLD]: phaseFromES(phases[PHASE_COLD], PHASE_COLD, defaultColdPhase), + [PHASE_DELETE]: phaseFromES(phases[PHASE_DELETE], PHASE_DELETE, defaultDeletePhase) + }, + isNew: false, + saveAsNew: false + }; +}; + +export const phaseToES = (state, phase) => { + const esPhase = {}; + + if (!phase[PHASE_ENABLED]) { + return esPhase; + } + + if (isNumber(phase[PHASE_ROLLOVER_MINIMUM_AGE])) { + esPhase.min_age = `${phase[PHASE_ROLLOVER_MINIMUM_AGE]}${phase[PHASE_ROLLOVER_MINIMUM_AGE_UNITS]}`; + } + + esPhase.actions = {}; + + if (phase[PHASE_ROLLOVER_ENABLED]) { + esPhase.actions.rollover = {}; + + if (isNumber(phase[PHASE_ROLLOVER_MAX_AGE])) { + esPhase.actions.rollover.max_age = `${phase[PHASE_ROLLOVER_MAX_AGE]}${ + phase[PHASE_ROLLOVER_MAX_AGE_UNITS] + }`; + } + if (isNumber(phase[PHASE_ROLLOVER_MAX_SIZE_STORED])) { + if (phase[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS] === MAX_SIZE_TYPE_DOCUMENT) { + esPhase.actions.rollover.max_docs = phase[PHASE_ROLLOVER_MAX_SIZE_STORED]; + } else { + esPhase.actions.rollover.max_size = `${phase[PHASE_ROLLOVER_MAX_SIZE_STORED]}${ + phase[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS] + }`; + } + } + } + if (phase[PHASE_NODE_ATTRS]) { + const [ name, value, ] = phase[PHASE_NODE_ATTRS].split(':'); + esPhase.actions.allocate = { + include: {}, + exclude: {}, + require: { + [name]: value + } + }; + } + if (isNumber(phase[PHASE_REPLICA_COUNT])) { + esPhase.actions.allocate = esPhase.actions.allocate || {}; + esPhase.actions.allocate.number_of_replicas = phase[PHASE_REPLICA_COUNT]; + } + + if (phase[PHASE_FORCE_MERGE_ENABLED]) { + esPhase.actions.forcemerge = { + max_num_segments: phase[PHASE_FORCE_MERGE_SEGMENTS] + }; + } + + if (phase[PHASE_SHRINK_ENABLED] && isNumber(phase[PHASE_PRIMARY_SHARD_COUNT])) { + esPhase.actions.shrink = { + number_of_shards: phase[PHASE_PRIMARY_SHARD_COUNT] + }; + } + return esPhase; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/store/store.js b/x-pack/plugins/index_lifecycle_management/public/store/store.js new file mode 100644 index 0000000000000..151a7a5bf8b50 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/store/store.js @@ -0,0 +1,28 @@ +/* + * 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 { + createStore, + applyMiddleware, + compose +} from 'redux'; +import thunk from 'redux-thunk'; + +import { + indexLifecycleManagement +} from './reducers/'; + + +export const indexLifecycleManagementStore = (initialState = {}) => { + const enhancers = [applyMiddleware(thunk)]; + + window.__REDUX_DEVTOOLS_EXTENSION__ && enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__()); + return createStore( + indexLifecycleManagement, + initialState, + compose(...enhancers) + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/call_with_request_factory/call_with_request_factory.js b/x-pack/plugins/index_lifecycle_management/server/lib/call_with_request_factory/call_with_request_factory.js new file mode 100644 index 0000000000000..b9a77a1a0362b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/call_with_request_factory/call_with_request_factory.js @@ -0,0 +1,18 @@ +/* + * 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 { once } from 'lodash'; + +const callWithRequest = once((server) => { + const cluster = server.plugins.elasticsearch.getCluster('data'); + return cluster.callWithRequest; +}); + +export const callWithRequestFactory = (server, request) => { + return (...args) => { + return callWithRequest(server)(request, ...args); + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/call_with_request_factory/index.js b/x-pack/plugins/index_lifecycle_management/server/lib/call_with_request_factory/index.js new file mode 100644 index 0000000000000..787814d87dff9 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/call_with_request_factory/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { callWithRequestFactory } from './call_with_request_factory'; diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/check_license/__tests__/check_license.js b/x-pack/plugins/index_lifecycle_management/server/lib/check_license/__tests__/check_license.js new file mode 100644 index 0000000000000..19a7b56759269 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/check_license/__tests__/check_license.js @@ -0,0 +1,146 @@ +/* + * 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 expect from 'expect.js'; +import { set } from 'lodash'; +import { checkLicense } from '../check_license'; + +describe('check_license', function () { + + let mockLicenseInfo; + beforeEach(() => mockLicenseInfo = {}); + + describe('license information is undefined', () => { + beforeEach(() => mockLicenseInfo = undefined); + + it('should set isAvailable to false', () => { + expect(checkLicense(mockLicenseInfo).isAvailable).to.be(false); + }); + + it('should set showLinks to true', () => { + expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); + }); + + it('should set enableLinks to false', () => { + expect(checkLicense(mockLicenseInfo).enableLinks).to.be(false); + }); + + it('should set a message', () => { + expect(checkLicense(mockLicenseInfo).message).to.not.be(undefined); + }); + }); + + describe('license information is not available', () => { + beforeEach(() => mockLicenseInfo.isAvailable = () => false); + + it('should set isAvailable to false', () => { + expect(checkLicense(mockLicenseInfo).isAvailable).to.be(false); + }); + + it('should set showLinks to true', () => { + expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); + }); + + it('should set enableLinks to false', () => { + expect(checkLicense(mockLicenseInfo).enableLinks).to.be(false); + }); + + it('should set a message', () => { + expect(checkLicense(mockLicenseInfo).message).to.not.be(undefined); + }); + }); + + describe('license information is available', () => { + beforeEach(() => { + mockLicenseInfo.isAvailable = () => true; + set(mockLicenseInfo, 'license.getType', () => 'basic'); + }); + + describe('& license is trial, standard, gold, platinum', () => { + beforeEach(() => set(mockLicenseInfo, 'license.isOneOf', () => true)); + + describe('& license is active', () => { + beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => true)); + + it('should set isAvailable to true', () => { + expect(checkLicense(mockLicenseInfo).isAvailable).to.be(true); + }); + + it ('should set showLinks to true', () => { + expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); + }); + + it ('should set enableLinks to true', () => { + expect(checkLicense(mockLicenseInfo).enableLinks).to.be(true); + }); + + it('should not set a message', () => { + expect(checkLicense(mockLicenseInfo).message).to.be(undefined); + }); + }); + + describe('& license is expired', () => { + beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => false)); + + it('should set isAvailable to false', () => { + expect(checkLicense(mockLicenseInfo).isAvailable).to.be(false); + }); + + it ('should set showLinks to true', () => { + expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); + }); + + it ('should set enableLinks to false', () => { + expect(checkLicense(mockLicenseInfo).enableLinks).to.be(false); + }); + + it('should set a message', () => { + expect(checkLicense(mockLicenseInfo).message).to.not.be(undefined); + }); + }); + }); + + describe('& license is basic', () => { + beforeEach(() => set(mockLicenseInfo, 'license.isOneOf', () => true)); + + describe('& license is active', () => { + beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => true)); + + it('should set isAvailable to true', () => { + expect(checkLicense(mockLicenseInfo).isAvailable).to.be(true); + }); + + it ('should set showLinks to true', () => { + expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); + }); + + it ('should set enableLinks to true', () => { + expect(checkLicense(mockLicenseInfo).enableLinks).to.be(true); + }); + + it('should not set a message', () => { + expect(checkLicense(mockLicenseInfo).message).to.be(undefined); + }); + }); + + describe('& license is expired', () => { + beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => false)); + + it('should set isAvailable to false', () => { + expect(checkLicense(mockLicenseInfo).isAvailable).to.be(false); + }); + + it ('should set showLinks to true', () => { + expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); + }); + + it('should set a message', () => { + expect(checkLicense(mockLicenseInfo).message).to.not.be(undefined); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/check_license/check_license.js b/x-pack/plugins/index_lifecycle_management/server/lib/check_license/check_license.js new file mode 100644 index 0000000000000..4dff66dffa8d1 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/check_license/check_license.js @@ -0,0 +1,67 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +export function checkLicense(xpackLicenseInfo) { + const pluginName = 'Index Management'; + + // If, for some reason, we cannot get the license information + // from Elasticsearch, assume worst case and disable + if (!xpackLicenseInfo || !xpackLicenseInfo.isAvailable()) { + return { + isAvailable: false, + showLinks: true, + enableLinks: false, + message: i18n.translate('xpack.idxMgmt.indexLifecycleMgmtSummary.headers.lifecyclePolicyHeader', { + defaultMessage: `You cannot use {pluginName} because license information is not available at this time.`, + values: { pluginName } + }) + }; + } + + const VALID_LICENSE_MODES = [ + 'trial', + 'basic', + 'standard', + 'gold', + 'platinum' + ]; + + const isLicenseModeValid = xpackLicenseInfo.license.isOneOf(VALID_LICENSE_MODES); + const isLicenseActive = xpackLicenseInfo.license.isActive(); + const licenseType = xpackLicenseInfo.license.getType(); + + // License is not valid + if (!isLicenseModeValid) { + return { + isAvailable: false, + showLinks: false, + message: i18n.translate('xpack.idxMgmt.indexLifecycleMgmtSummary.headers.lifecyclePolicyHeader', { + defaultMessage: `Your {licenseType} license does not support ${pluginName}. Please upgrade your license.`, + values: { licenseType } + }) + }; + } + + // License is valid but not active + if (!isLicenseActive) { + return { + isAvailable: false, + showLinks: true, + enableLinks: false, + message: i18n.translate('xpack.idxMgmt.indexLifecycleMgmtSummary.headers.lifecyclePolicyHeader', { + defaultMessage: `You cannot use {pluginName} because your {licenseType} license has expired.`, + values: { pluginName, licenseType } + }) + }; + } + + // License is valid and active + return { + isAvailable: true, + showLinks: true, + enableLinks: true + }; +} diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/check_license/index.js b/x-pack/plugins/index_lifecycle_management/server/lib/check_license/index.js new file mode 100644 index 0000000000000..f2c070fd44b6e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/check_license/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { checkLicense } from './check_license'; diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_custom_error.js b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_custom_error.js new file mode 100644 index 0000000000000..443744ccb0cc8 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_custom_error.js @@ -0,0 +1,21 @@ +/* + * 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 expect from 'expect.js'; +import { wrapCustomError } from '../wrap_custom_error'; + +describe('wrap_custom_error', () => { + describe('#wrapCustomError', () => { + it('should return a Boom object', () => { + const originalError = new Error('I am an error'); + const statusCode = 404; + const wrappedError = wrapCustomError(originalError, statusCode); + + expect(wrappedError.isBoom).to.be(true); + expect(wrappedError.output.statusCode).to.equal(statusCode); + }); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_es_error.js b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_es_error.js new file mode 100644 index 0000000000000..394c182140000 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_es_error.js @@ -0,0 +1,39 @@ +/* + * 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 expect from 'expect.js'; +import { wrapEsError } from '../wrap_es_error'; + +describe('wrap_es_error', () => { + describe('#wrapEsError', () => { + + let originalError; + beforeEach(() => { + originalError = new Error('I am an error'); + originalError.statusCode = 404; + }); + + it('should return a Boom object', () => { + const wrappedError = wrapEsError(originalError); + + expect(wrappedError.isBoom).to.be(true); + }); + + it('should return the correct Boom object', () => { + const wrappedError = wrapEsError(originalError); + + expect(wrappedError.output.statusCode).to.be(originalError.statusCode); + expect(wrappedError.output.payload.message).to.be(originalError.message); + }); + + it('should return the correct Boom object with custom message', () => { + const wrappedError = wrapEsError(originalError, { 404: 'No encontrado!' }); + + expect(wrappedError.output.statusCode).to.be(originalError.statusCode); + expect(wrappedError.output.payload.message).to.be('No encontrado!'); + }); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_unknown_error.js b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_unknown_error.js new file mode 100644 index 0000000000000..6d6a336417bef --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_unknown_error.js @@ -0,0 +1,19 @@ +/* + * 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 expect from 'expect.js'; +import { wrapUnknownError } from '../wrap_unknown_error'; + +describe('wrap_unknown_error', () => { + describe('#wrapUnknownError', () => { + it('should return a Boom object', () => { + const originalError = new Error('I am an error'); + const wrappedError = wrapUnknownError(originalError); + + expect(wrappedError.isBoom).to.be(true); + }); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/index.js b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/index.js new file mode 100644 index 0000000000000..f275f15637091 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/index.js @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { wrapCustomError } from './wrap_custom_error'; +export { wrapEsError } from './wrap_es_error'; +export { wrapUnknownError } from './wrap_unknown_error'; diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_custom_error.js b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_custom_error.js new file mode 100644 index 0000000000000..3295113d38ee5 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_custom_error.js @@ -0,0 +1,18 @@ +/* + * 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 Boom from 'boom'; + +/** + * Wraps a custom error into a Boom error response and returns it + * + * @param err Object error + * @param statusCode Error status code + * @return Object Boom error response + */ +export function wrapCustomError(err, statusCode) { + return Boom.boomify(err, { statusCode }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_es_error.js b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_es_error.js new file mode 100644 index 0000000000000..2df2e4b802e1a --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_es_error.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; + +/** + * Wraps an error thrown by the ES JS client into a Boom error response and returns it + * + * @param err Object Error thrown by ES JS client + * @param statusCodeToMessageMap Object Optional map of HTTP status codes => error messages + * @return Object Boom error response + */ +export function wrapEsError(err, statusCodeToMessageMap = {}) { + + const statusCode = err.statusCode; + + // If no custom message if specified for the error's status code, just + // wrap the error as a Boom error response and return it + if (!statusCodeToMessageMap[statusCode]) { + return Boom.boomify(err, { statusCode }); + } + + // Otherwise, use the custom message to create a Boom error response and + // return it + const message = statusCodeToMessageMap[statusCode]; + return new Boom(message, { statusCode }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_unknown_error.js b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_unknown_error.js new file mode 100644 index 0000000000000..4b865880ae20d --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_unknown_error.js @@ -0,0 +1,17 @@ +/* + * 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 Boom from 'boom'; + +/** + * Wraps an unknown error into a Boom error response and returns it + * + * @param err Object Unknown error + * @return Object Boom error response + */ +export function wrapUnknownError(err) { + return Boom.boomify(err); +} \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js b/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js new file mode 100644 index 0000000000000..d50ff9480d3e4 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js @@ -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 expect from 'expect.js'; +import { isEsErrorFactory } from '../is_es_error_factory'; +import { set } from 'lodash'; + +class MockAbstractEsError {} + +describe('is_es_error_factory', () => { + + let mockServer; + let isEsError; + + beforeEach(() => { + const mockEsErrors = { + _Abstract: MockAbstractEsError + }; + mockServer = {}; + set(mockServer, 'plugins.elasticsearch.getCluster', () => ({ errors: mockEsErrors })); + + isEsError = isEsErrorFactory(mockServer); + }); + + describe('#isEsErrorFactory', () => { + + it('should return a function', () => { + expect(isEsError).to.be.a(Function); + }); + + describe('returned function', () => { + + it('should return true if passed-in err is a known esError', () => { + const knownEsError = new MockAbstractEsError(); + expect(isEsError(knownEsError)).to.be(true); + }); + + it('should return false if passed-in err is not a known esError', () => { + const unknownEsError = {}; + expect(isEsError(unknownEsError)).to.be(false); + + }); + }); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error_factory/index.js b/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error_factory/index.js new file mode 100644 index 0000000000000..441648a8701e0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error_factory/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { isEsErrorFactory } from './is_es_error_factory'; diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error_factory/is_es_error_factory.js b/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error_factory/is_es_error_factory.js new file mode 100644 index 0000000000000..80daac5bd496d --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error_factory/is_es_error_factory.js @@ -0,0 +1,18 @@ +/* + * 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 { memoize } from 'lodash'; + +const esErrorsFactory = memoize((server) => { + return server.plugins.elasticsearch.getCluster('admin').errors; +}); + +export function isEsErrorFactory(server) { + const esErrors = esErrorsFactory(server); + return function isEsError(err) { + return err instanceof esErrors._Abstract; + }; +} diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js b/x-pack/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js new file mode 100644 index 0000000000000..359b3fb2ce6f4 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js @@ -0,0 +1,69 @@ +/* + * 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 expect from 'expect.js'; +import { licensePreRoutingFactory } from '../license_pre_routing_factory'; + +describe('license_pre_routing_factory', () => { + describe('#reportingFeaturePreRoutingFactory', () => { + let mockServer; + let mockLicenseCheckResults; + + beforeEach(() => { + mockServer = { + plugins: { + xpack_main: { + info: { + feature: () => ({ + getLicenseCheckResults: () => mockLicenseCheckResults + }) + } + } + } + }; + }); + + it('only instantiates one instance per server', () => { + const firstInstance = licensePreRoutingFactory(mockServer); + const secondInstance = licensePreRoutingFactory(mockServer); + + expect(firstInstance).to.be(secondInstance); + }); + + describe('isAvailable is false', () => { + beforeEach(() => { + mockLicenseCheckResults = { + isAvailable: false + }; + }); + + it ('replies with 403', () => { + const licensePreRouting = licensePreRoutingFactory(mockServer); + const stubRequest = {}; + expect(() => licensePreRouting(stubRequest)).to.throwException((response) => { + expect(response).to.be.an(Error); + expect(response.isBoom).to.be(true); + expect(response.output.statusCode).to.be(403); + }); + }); + }); + + describe('isAvailable is true', () => { + beforeEach(() => { + mockLicenseCheckResults = { + isAvailable: true + }; + }); + + it ('replies with nothing', () => { + const licensePreRouting = licensePreRoutingFactory(mockServer); + const stubRequest = {}; + const response = licensePreRouting(stubRequest); + expect(response).to.be(null); + }); + }); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/index.js b/x-pack/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/index.js new file mode 100644 index 0000000000000..0743e443955f4 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { licensePreRoutingFactory } from './license_pre_routing_factory'; diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/license_pre_routing_factory.js b/x-pack/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/license_pre_routing_factory.js new file mode 100644 index 0000000000000..af2b3acb287c7 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/license_pre_routing_factory.js @@ -0,0 +1,28 @@ +/* + * 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 { once } from 'lodash'; +import { wrapCustomError } from '../error_wrappers'; +import { PLUGIN_ID } from '../../../common/constants'; + +export const licensePreRoutingFactory = once((server) => { + const xpackMainPlugin = server.plugins.xpack_main; + + // License checking and enable/disable logic + function licensePreRouting() { + const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN_ID).getLicenseCheckResults(); + if (!licenseCheckResults.isAvailable) { + const error = new Error(licenseCheckResults.message); + const statusCode = 403; + throw wrapCustomError(error, statusCode); + } + + return null; + } + + return licensePreRouting; +}); + diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/register_license_checker/index.js b/x-pack/plugins/index_lifecycle_management/server/lib/register_license_checker/index.js new file mode 100644 index 0000000000000..7b0f97c38d129 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/register_license_checker/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { registerLicenseChecker } from './register_license_checker'; diff --git a/x-pack/plugins/index_lifecycle_management/server/lib/register_license_checker/register_license_checker.js b/x-pack/plugins/index_lifecycle_management/server/lib/register_license_checker/register_license_checker.js new file mode 100644 index 0000000000000..260fc15c7a83f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/lib/register_license_checker/register_license_checker.js @@ -0,0 +1,21 @@ +/* + * 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 { mirrorPluginStatus } from '../../../../../server/lib/mirror_plugin_status'; +import { checkLicense } from '../check_license'; +import { PLUGIN_ID } from '../../../common/constants'; + +export function registerLicenseChecker(server) { + const xpackMainPlugin = server.plugins.xpack_main; + const ilmPlugin = server.plugins.index_lifecycle_management; + + mirrorPluginStatus(xpackMainPlugin, ilmPlugin); + xpackMainPlugin.status.once('green', () => { + // Register a function that is called whenever the xpack info changes, + // to re-compute the license check results for this plugin + xpackMainPlugin.info.feature(PLUGIN_ID).registerLicenseCheckResultsGenerator(checkLicense); + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/index.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/index.js new file mode 100644 index 0000000000000..82fb2e3b2a372 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { registerIndexRoutes } from './register_index_routes'; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.js new file mode 100644 index 0000000000000..2e64758bade0b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.js @@ -0,0 +1,54 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; + +async function addLifecyclePolicy(callWithRequest, indexName, policyName, alias) { + const body = { + lifecycle: { + name: policyName, + rollover_alias: alias + } + }; + + const params = { + method: 'PUT', + path: `/${encodeURIComponent(indexName)}/_settings`, + body + }; + + return callWithRequest('transport.request', params); +} + +export function registerAddPolicyRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/index/add', + method: 'POST', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { indexName, policyName, alias } = request.payload; + try { + const response = await addLifecyclePolicy(callWithRequest, indexName, policyName, alias); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_bootstrap_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_bootstrap_route.js new file mode 100644 index 0000000000000..ad449101cb4f5 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_bootstrap_route.js @@ -0,0 +1,50 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; + +async function bootstrap(callWithRequest, payload) { + await callWithRequest('indices.create', { + index: payload.indexName, + body: { + aliases: { + [payload.aliasName]: { + is_write_alias: true + } + }, + } + }); +} + +export function registerBootstrapRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/indices/bootstrap', + method: 'POST', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const response = await bootstrap(callWithRequest, request.payload); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [licensePreRouting] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_get_affected_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_get_affected_route.js new file mode 100644 index 0000000000000..ed5b6b09db16b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_get_affected_route.js @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; + +async function fetchTemplates(callWithRequest) { + const params = { + method: 'GET', + path: '/_template', + // we allow 404 in case there are no templates + ignore: [404] + }; + + return await callWithRequest('transport.request', params); +} + +async function getAffectedIndices( + callWithRequest, + indexTemplateName, + policyName +) { + const templates = await fetchTemplates(callWithRequest); + + if (!templates || Object.keys(templates).length === 0 || templates.status === 404) { + return []; + } + + const indexPatterns = Object.entries(templates).reduce((accum, [templateName, template]) => { + const isMatchingTemplate = templateName === indexTemplateName; + const isMatchingPolicy = ( + policyName && + template.settings && + template.settings.index && + template.settings.index.lifecycle && + template.settings.index.lifecycle.name === policyName + ); + if (isMatchingTemplate || isMatchingPolicy) { + accum.push(...template.index_patterns); + } + return accum; + }, []); + + if (indexPatterns.length === 0) { + return []; + } + const indexParams = { + method: 'GET', + path: `/${indexPatterns.join(',')}`, + // we allow 404 in case there are no indices + ignore: [404] + }; + const indices = await callWithRequest('transport.request', indexParams); + + if (!indices || indices.status === 404) { + return []; + } + + return Object.keys(indices); +} + +export function registerGetAffectedRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: + '/api/index_lifecycle_management/indices/affected/{indexTemplateName}', + method: 'GET', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const response = await getAffectedIndices( + callWithRequest, + request.params.indexTemplateName, + ); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [licensePreRouting] + } + }); + + server.route({ + path: + '/api/index_lifecycle_management/indices/affected/{indexTemplateName}/{policyName}', + method: 'GET', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const response = await getAffectedIndices( + callWithRequest, + request.params.indexTemplateName, + request.params.policyName + ); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [licensePreRouting] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_index_routes.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_index_routes.js new file mode 100644 index 0000000000000..e5c210f16632e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_index_routes.js @@ -0,0 +1,19 @@ +/* + * 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 { registerBootstrapRoute } from './register_bootstrap_route'; +import { registerGetAffectedRoute } from './register_get_affected_route'; +import { registerRetryRoute } from './register_retry_route'; +import { registerRemoveRoute } from './register_remove_route'; +import { registerAddPolicyRoute } from './register_add_policy_route'; + +export function registerIndexRoutes(server) { + registerBootstrapRoute(server); + registerGetAffectedRoute(server); + registerRetryRoute(server); + registerRemoveRoute(server); + registerAddPolicyRoute(server); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.js new file mode 100644 index 0000000000000..943c584ea6cce --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.js @@ -0,0 +1,52 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; + +async function removeLifecycle(callWithRequest, indexNames) { + const responses = []; + for (let i = 0; i < indexNames.length; i++) { + const indexName = indexNames[i]; + const params = { + method: 'POST', + path: `/${encodeURIComponent(indexName)}/_ilm/remove`, + ignore: [ 404 ], + }; + + responses.push(callWithRequest('transport.request', params)); + } + return Promise.all(responses); +} + +export function registerRemoveRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/index/remove', + method: 'POST', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const response = await removeLifecycle(callWithRequest, request.payload.indexNames); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.js new file mode 100644 index 0000000000000..986e4bc794a7a --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.js @@ -0,0 +1,52 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; + +async function retryLifecycle(callWithRequest, indexNames) { + const responses = []; + for (let i = 0; i < indexNames.length; i++) { + const indexName = indexNames[i]; + const params = { + method: 'POST', + path: `/${encodeURIComponent(indexName)}/_ilm/retry`, + ignore: [ 404 ], + }; + + responses.push(callWithRequest('transport.request', params)); + } + return Promise.all(responses); +} + +export function registerRetryRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/index/retry', + method: 'POST', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const response = await retryLifecycle(callWithRequest, request.payload.indexNames); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/lifecycle/index.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/lifecycle/index.js new file mode 100644 index 0000000000000..17f52a723405d --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/lifecycle/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { registerLifecycleRoutes } from './register_lifecycle_routes'; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/lifecycle/register_create_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/lifecycle/register_create_route.js new file mode 100644 index 0000000000000..88db36d8c642c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/lifecycle/register_create_route.js @@ -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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; + +async function createLifecycle(callWithRequest, lifecycle) { + const body = { + policy: { + phases: lifecycle.phases, + } + }; + const params = { + method: 'PUT', + path: `/_ilm/policy/${encodeURIComponent(lifecycle.name)}`, + ignore: [ 404 ], + body, + }; + + return await callWithRequest('transport.request', params); +} + +export function registerCreateRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/lifecycle', + method: 'POST', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const response = await createLifecycle(callWithRequest, request.payload.lifecycle); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/lifecycle/register_lifecycle_routes.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/lifecycle/register_lifecycle_routes.js new file mode 100644 index 0000000000000..ba179d14b8112 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/lifecycle/register_lifecycle_routes.js @@ -0,0 +1,11 @@ +/* + * 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 { registerCreateRoute } from './register_create_route'; + +export function registerLifecycleRoutes(server) { + registerCreateRoute(server); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/constants.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/constants.js new file mode 100644 index 0000000000000..2e2b84a76002e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/constants.js @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export const NODE_ATTRS_KEYS_TO_IGNORE = [ + 'ml.enabled', + 'ml.machine_memory', + 'ml.max_open_jobs', + 'testattr', + 'xpack.installed', +]; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/index.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/index.js new file mode 100644 index 0000000000000..ef0ac271ae60e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { registerNodesRoutes } from './register_nodes_routes'; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.js new file mode 100644 index 0000000000000..9547e66c73dc3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.js @@ -0,0 +1,65 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; + +function findMatchingNodes(stats, nodeAttrs) { + return Object.entries(stats.nodes).reduce((accum, [nodeId, stats]) => { + const attributes = stats.attributes || {}; + for (const [key, value] of Object.entries(attributes)) { + if (`${key}:${value}` === nodeAttrs) { + accum.push({ + nodeId, + stats, + }); + break; + } + } + return accum; + }, []); +} + +async function fetchNodeStats(callWithRequest) { + const params = { + format: 'json' + }; + + return await callWithRequest('nodes.stats', params); +} + +export function registerDetailsRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/nodes/{nodeAttrs}/details', + method: 'GET', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const stats = await fetchNodeStats(callWithRequest); + const response = findMatchingNodes(stats, request.params.nodeAttrs); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.js new file mode 100644 index 0000000000000..e5aed9fbd260b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.js @@ -0,0 +1,67 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; +import { NODE_ATTRS_KEYS_TO_IGNORE } from './constants'; + +function convertStatsIntoList(stats, attributesToBeFiltered) { + return Object.entries(stats.nodes).reduce((accum, [nodeId, stats]) => { + const attributes = stats.attributes || {}; + for (const [key, value] of Object.entries(attributes)) { + if (!attributesToBeFiltered.includes(key)) { + const attributeString = `${key}:${value}`; + accum[attributeString] = accum[attributeString] || []; + accum[attributeString].push(nodeId); + } + } + return accum; + }, {}); +} + +async function fetchNodeStats(callWithRequest) { + const params = { + format: 'json' + }; + + return await callWithRequest('nodes.stats', params); +} + +export function registerListRoute(server) { + const config = server.config(); + const filteredNodeAttributes = config.get('xpack.ilm.filteredNodeAttributes'); + const attributesToBeFiltered = [...NODE_ATTRS_KEYS_TO_IGNORE, ...filteredNodeAttributes]; + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/nodes/list', + method: 'GET', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const stats = await fetchNodeStats(callWithRequest); + const response = convertStatsIntoList(stats, attributesToBeFiltered); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_nodes_routes.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_nodes_routes.js new file mode 100644 index 0000000000000..341f1d4f1ebf3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_nodes_routes.js @@ -0,0 +1,13 @@ +/* + * 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 { registerListRoute } from './register_list_route'; +import { registerDetailsRoute } from './register_details_route'; + +export function registerNodesRoutes(server) { + registerListRoute(server); + registerDetailsRoute(server); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/index.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/index.js new file mode 100644 index 0000000000000..7c6103a3389ab --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { registerPoliciesRoutes } from './register_policies_routes'; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.js new file mode 100644 index 0000000000000..7a13ca4062fc1 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.js @@ -0,0 +1,47 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; + +async function deletePolicies(policyNames, callWithRequest) { + const params = { + method: 'DELETE', + path: `/_ilm/policy/${encodeURIComponent(policyNames)}`, + // we allow 404 since they may have no policies + ignore: [ 404 ] + }; + + return await callWithRequest('transport.request', params); +} + +export function registerDeleteRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/policies/{policyNames}', + method: 'DELETE', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { policyNames } = request.params; + try { + await deletePolicies(policyNames, callWithRequest); + return {}; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.js new file mode 100644 index 0000000000000..df2c0096cddff --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.js @@ -0,0 +1,84 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; + +function formatPolicies(policiesMap) { + if (policiesMap.status === 404) { + return []; + } + return Object.keys(policiesMap).reduce((accum, lifecycleName) => { + const policyEntry = policiesMap[lifecycleName]; + accum.push({ + ...policyEntry, + name: lifecycleName, + }); + return accum; + }, []); +} + +async function fetchPolicies(callWithRequest) { + const params = { + method: 'GET', + path: '/_ilm/policy', + // we allow 404 since they may have no policies + ignore: [ 404 ] + }; + + return await callWithRequest('transport.request', params); +} +async function addCoveredIndices(policiesMap, callWithRequest) { + if (policiesMap.status === 404) { + return policiesMap; + } + const params = { + method: 'GET', + path: '/*/_ilm/explain', + // we allow 404 since they may have no policies + ignore: [ 404 ] + }; + + const policyExplanation = await callWithRequest('transport.request', params); + Object.entries(policyExplanation.indices).forEach(([indexName, { policy }]) => { + if (policy) { + policiesMap[policy].coveredIndices = policiesMap[policy].coveredIndices || []; + policiesMap[policy].coveredIndices.push(indexName); + } + }); +} + +export function registerFetchRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/policies', + method: 'GET', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { withIndices } = request.query; + try { + const policiesMap = await fetchPolicies(callWithRequest); + if (withIndices) { + await addCoveredIndices(policiesMap, callWithRequest); + } + return formatPolicies(policiesMap); + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_policies_routes.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_policies_routes.js new file mode 100644 index 0000000000000..40cf2430641b5 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_policies_routes.js @@ -0,0 +1,13 @@ +/* + * 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 { registerFetchRoute } from './register_fetch_route'; +import { registerDeleteRoute } from './register_delete_route'; + +export function registerPoliciesRoutes(server) { + registerFetchRoute(server); + registerDeleteRoute(server); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/index.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/index.js new file mode 100644 index 0000000000000..dc9a0acaaf09b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { registerTemplatesRoutes } from './register_templates_routes'; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.js new file mode 100644 index 0000000000000..ba6d42e720023 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.js @@ -0,0 +1,70 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; +import { merge } from 'lodash'; + +async function getIndexTemplate(callWithRequest, templateName) { + const response = await callWithRequest('indices.getTemplate', { name: templateName }); + return response[templateName]; +} + +async function updateIndexTemplate(callWithRequest, indexTemplatePatch) { + // Fetch existing template + const template = await getIndexTemplate(callWithRequest, indexTemplatePatch.templateName); + merge(template, { + settings: { + index: { + lifecycle: { + name: indexTemplatePatch.policyName, + rollover_alias: indexTemplatePatch.aliasName + }, + } + } + }); + + const params = { + method: 'PUT', + path: `/_template/${indexTemplatePatch.templateName}`, + ignore: [ 404 ], + body: template, + }; + + return await callWithRequest('transport.request', params); +} + +export function registerAddPolicyRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/template', + method: 'POST', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const response = await updateIndexTemplate(callWithRequest, request.payload); + return response; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} \ No newline at end of file diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.js new file mode 100644 index 0000000000000..c2bdeccc39a52 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.js @@ -0,0 +1,81 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; +import { any } from 'lodash'; + +function isReservedSystemTemplate(templateName, indexPattens) { + return templateName.startsWith('kibana_index_template') || + ( + templateName.startsWith('.') && + !any(indexPattens, (pattern) => { + return pattern.includes('*'); + }) + ); +} +async function filterAndFormatTemplates(templates) { + const formattedTemplates = []; + const templateNames = Object.keys(templates); + for (const templateName of templateNames) { + const { settings, index_patterns } = templates[templateName]; // eslint-disable-line camelcase + if (isReservedSystemTemplate(templateName, index_patterns)) { + continue; + } + const formattedTemplate = { + index_lifecycle_name: settings.index && settings.index.lifecycle ? settings.index.lifecycle.name : undefined, + index_patterns, + allocation_rules: settings.index && settings.index.routing ? settings.index.routing : undefined, + settings, + name: templateName, + }; + formattedTemplates.push(formattedTemplate); + } + return formattedTemplates; +} + +async function fetchTemplates(callWithRequest) { + const params = { + method: 'GET', + path: '/_template', + // we allow 404 incase the user shutdown security in-between the check and now + ignore: [ 404 ] + }; + + return await callWithRequest('transport.request', params); +} +export function registerFetchRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/templates', + method: 'GET', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const hits = await fetchTemplates(callWithRequest); + const templates = await filterAndFormatTemplates(hits); + return templates; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_get_route.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_get_route.js new file mode 100644 index 0000000000000..ad24160fe798f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_get_route.js @@ -0,0 +1,52 @@ +/* + * 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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; + +async function fetchTemplate(callWithRequest, templateName) { + const params = { + method: 'GET', + path: `/_template/${templateName}`, + // we allow 404 incase the user shutdown security in-between the check and now + ignore: [ 404 ] + }; + + return await callWithRequest('transport.request', params); +} + +export function registerGetRoute(server) { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: '/api/index_lifecycle_management/template/{templateName}', + method: 'GET', + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const templateName = request.params.templateName; + + try { + const template = await fetchTemplate(callWithRequest, templateName); + return template[templateName]; + } catch (err) { + if (isEsError(err)) { + return wrapEsError(err); + } + + return wrapUnknownError(err); + } + }, + config: { + pre: [ licensePreRouting ] + } + }); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_templates_routes.js b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_templates_routes.js new file mode 100644 index 0000000000000..3f737d1f70f56 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_templates_routes.js @@ -0,0 +1,18 @@ +/* + * 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 { registerFetchRoute } from './register_fetch_route'; +import { registerGetRoute } from './register_get_route'; +import { registerAddPolicyRoute } from './register_add_policy_route'; + +export function registerTemplatesRoutes(server) { + registerFetchRoute(server); + registerGetRoute(server); + registerAddPolicyRoute(server); +} diff --git a/x-pack/plugins/index_management/__jest__/components/index_table.test.js b/x-pack/plugins/index_management/__jest__/components/index_table.test.js index 31c0d6e7e1503..b6cb1947b5d18 100644 --- a/x-pack/plugins/index_management/__jest__/components/index_table.test.js +++ b/x-pack/plugins/index_management/__jest__/components/index_table.test.js @@ -121,7 +121,7 @@ describe('index table', () => { store = indexManagementStore(); component = ( - + @@ -198,8 +198,9 @@ describe('index table', () => { }); test('should filter based on content of search input', () => { const rendered = mountWithIntl(component); - const searchInput = findTestSubject(rendered, 'indexTableFilterInput'); - searchInput.simulate('change', { target: { value: 'testy0' } }); + const searchInput = rendered.find('.euiFieldSearch').first(); + searchInput.instance().value = 'testy0'; + searchInput.simulate('keyup', { key: 'Enter', keyCode: 13, which: 13 }); rendered.update(); snapshot(namesText(rendered)); }); diff --git a/x-pack/plugins/index_management/index.js b/x-pack/plugins/index_management/index.js index 4408428364b83..c48eaa6ba15ca 100644 --- a/x-pack/plugins/index_management/index.js +++ b/x-pack/plugins/index_management/index.js @@ -11,7 +11,7 @@ import { registerSettingsRoutes } from './server/routes/api/settings'; import { registerStatsRoute } from './server/routes/api/stats'; import { registerLicenseChecker } from './server/lib/register_license_checker'; import { PLUGIN } from './common/constants'; - +import { addIndexManagementDataEnricher } from "./index_management_data"; export function indexManagement(kibana) { return new kibana.Plugin({ id: PLUGIN.ID, @@ -24,6 +24,7 @@ export function indexManagement(kibana) { ] }, init: function (server) { + server.expose('addIndexManagementDataEnricher', addIndexManagementDataEnricher); registerLicenseChecker(server); registerIndicesRoutes(server); registerSettingsRoutes(server); diff --git a/x-pack/plugins/index_management/index_management_data.js b/x-pack/plugins/index_management/index_management_data.js new file mode 100644 index 0000000000000..60b2ffba236de --- /dev/null +++ b/x-pack/plugins/index_management/index_management_data.js @@ -0,0 +1,13 @@ +/* + * 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. + */ + +const indexManagementDataEnrichers = []; +export const addIndexManagementDataEnricher = (enricher) => { + indexManagementDataEnrichers.push(enricher); +}; +export const getIndexManagementDataEnrichers = () => { + return indexManagementDataEnrichers; +}; \ No newline at end of file diff --git a/x-pack/plugins/index_management/public/app.js b/x-pack/plugins/index_management/public/app.js index 6791edd562dae..c0b01f63919c7 100644 --- a/x-pack/plugins/index_management/public/app.js +++ b/x-pack/plugins/index_management/public/app.js @@ -12,7 +12,8 @@ import { IndexList } from './sections/index_list'; export const App = () => (
- + +
); diff --git a/x-pack/plugins/index_management/public/index.js b/x-pack/plugins/index_management/public/index.js index ccde49edbdf5d..d52bf02b82f65 100644 --- a/x-pack/plugins/index_management/public/index.js +++ b/x-pack/plugins/index_management/public/index.js @@ -6,3 +6,4 @@ import './register_management_section'; import './register_routes'; +import './index_management_extensions'; diff --git a/x-pack/plugins/index_management/public/index_management_extensions.js b/x-pack/plugins/index_management/public/index_management_extensions.js new file mode 100644 index 0000000000000..61a074e5d0766 --- /dev/null +++ b/x-pack/plugins/index_management/public/index_management_extensions.js @@ -0,0 +1,36 @@ +/* + * 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. + */ + +const summaryExtensions = []; +export const addSummaryExtension = (summaryExtension)=> { + summaryExtensions.push(summaryExtension); +}; +export const getSummaryExtensions = () => { + return summaryExtensions; +}; +const actionExtensions = []; +export const addActionExtension = (actionExtension)=> { + actionExtensions.push(actionExtension); +}; +export const getActionExtensions = () => { + return actionExtensions; +}; +const bannerExtensions = []; +export const addBannerExtension = (actionExtension)=> { + bannerExtensions.push(actionExtension); +}; +export const getBannerExtensions = () => { + return bannerExtensions; +}; +const filterExtensions = []; +export const addFilterExtension = (filterExtension)=> { + filterExtensions.push(filterExtension); +}; +export const getFilterExtensions = () => { + return filterExtensions; +}; + + diff --git a/x-pack/plugins/index_management/public/register_management_section.js b/x-pack/plugins/index_management/public/register_management_section.js index 78dec3515a899..08f0a6b1d486e 100644 --- a/x-pack/plugins/index_management/public/register_management_section.js +++ b/x-pack/plugins/index_management/public/register_management_section.js @@ -13,6 +13,6 @@ esSection.register('index_management', { visible: true, display: i18n.translate('xpack.idxMgmt.appTitle', { defaultMessage: 'Index Management' }), order: 1, - url: `#${BASE_PATH}home` + url: `#${BASE_PATH}indices` }); diff --git a/x-pack/plugins/index_management/public/register_routes.js b/x-pack/plugins/index_management/public/register_routes.js index a9623231e57d9..ca50a351e0a17 100644 --- a/x-pack/plugins/index_management/public/register_routes.js +++ b/x-pack/plugins/index_management/public/register_routes.js @@ -11,6 +11,7 @@ import { HashRouter } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; import { setHttpClient } from './services/api'; +import { setUrlService } from './services/navigation'; import { App } from './app'; import { BASE_PATH } from '../common/constants/base_path'; @@ -35,7 +36,7 @@ const renderReact = async (elem) => { ); }; -routes.when(`${BASE_PATH}:view?/:id?`, { +routes.when(`${BASE_PATH}:view?/:action?/:id?`, { template: template, k7Breadcrumbs: () => [ MANAGEMENT_BREADCRUMB, @@ -47,11 +48,16 @@ routes.when(`${BASE_PATH}:view?/:id?`, { ], controllerAs: 'indexManagement', controller: class IndexManagementController { - constructor($scope, $route, $http) { + constructor($scope, $route, $http, kbnUrl, $rootScope) { // NOTE: We depend upon Angular's $http service because it's decorated with interceptors, // e.g. to check license status per request. setHttpClient($http); - + setUrlService({ + change(url) { + kbnUrl.change(url); + $rootScope.$digest(); + } + }); $scope.$$postDigest(() => { const elem = document.getElementById('indexManagementReactRoot'); renderReact(elem); diff --git a/x-pack/plugins/index_management/public/sections/index_list/components/detail_panel/summary/summary.js b/x-pack/plugins/index_management/public/sections/index_list/components/detail_panel/summary/summary.js index 4813a67b53485..5a2aa973e914c 100644 --- a/x-pack/plugins/index_management/public/sections/index_list/components/detail_panel/summary/summary.js +++ b/x-pack/plugins/index_management/public/sections/index_list/components/detail_panel/summary/summary.js @@ -4,50 +4,76 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { healthToColor } from '../../../../../services'; +import { getUrlService } from '../../../../../services/navigation'; +import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiFlexGroup, + EuiFlexItem, EuiHealth, EuiDescriptionList, + EuiHorizontalRule, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiSpacer, + EuiTitle } from '@elastic/eui'; - -const HEADERS = { - health: i18n.translate('xpack.idxMgmt.summary.headers.healthHeader', { - defaultMessage: 'Health', - }), - status: i18n.translate('xpack.idxMgmt.summary.headers.statusHeader', { - defaultMessage: 'Status', - }), - primary: i18n.translate('xpack.idxMgmt.summary.headers.primaryHeader', { - defaultMessage: 'Primaries', - }), - replica: i18n.translate('xpack.idxMgmt.summary.headers.replicaHeader', { - defaultMessage: 'Replicas', - }), - documents: i18n.translate('xpack.idxMgmt.summary.headers.documentsHeader', { - defaultMessage: 'Docs Count', - }), - documents_deleted: i18n.translate('xpack.idxMgmt.summary.headers.deletedDocumentsHeader', { - defaultMessage: 'Docs Deleted', - }), - size: i18n.translate('xpack.idxMgmt.summary.headers.storageSizeHeader', { - defaultMessage: 'Storage Size', - }), - primary_size: i18n.translate('xpack.idxMgmt.summary.headers.primaryStorageSizeHeader', { - defaultMessage: 'Primary Storage Size', - }), - aliases: i18n.translate('xpack.idxMgmt.summary.headers.aliases', { - defaultMessage: 'Aliases' - }) +import { getSummaryExtensions } from '../../../../../index_management_extensions'; +const getHeaders = () =>{ + return { + health: i18n.translate('xpack.idxMgmt.summary.headers.healthHeader', { + defaultMessage: 'Health', + }), + status: i18n.translate('xpack.idxMgmt.summary.headers.statusHeader', { + defaultMessage: 'Status', + }), + primary: i18n.translate('xpack.idxMgmt.summary.headers.primaryHeader', { + defaultMessage: 'Primaries', + }), + replica: i18n.translate('xpack.idxMgmt.summary.headers.replicaHeader', { + defaultMessage: 'Replicas', + }), + documents: i18n.translate('xpack.idxMgmt.summary.headers.documentsHeader', { + defaultMessage: 'Docs Count', + }), + documents_deleted: i18n.translate('xpack.idxMgmt.summary.headers.deletedDocumentsHeader', { + defaultMessage: 'Docs Deleted', + }), + size: i18n.translate('xpack.idxMgmt.summary.headers.storageSizeHeader', { + defaultMessage: 'Storage Size', + }), + primary_size: i18n.translate('xpack.idxMgmt.summary.headers.primaryStorageSizeHeader', { + defaultMessage: 'Primary Storage Size', + }), + aliases: i18n.translate('xpack.idxMgmt.summary.headers.aliases', { + defaultMessage: 'Aliases' + }) + }; }; export class Summary extends React.PureComponent { + getAdditionalContent() { + const { index } = this.props; + const extensions = getSummaryExtensions(); + return extensions.map((summaryExtension, i) => { + return ( + + + { summaryExtension(index, getUrlService()) } + + ); + }); + } buildRows() { const { index } = this.props; - return Object.keys(HEADERS).map(fieldName => { + const headers = getHeaders(); + const rows = { + left: [], + right: [] + }; + Object.keys(headers).forEach((fieldName, arrayIndex) => { const value = index[fieldName]; let content = value; if(fieldName === 'health') { @@ -56,22 +82,51 @@ export class Summary extends React.PureComponent { if(Array.isArray(content)) { content = content.join(', '); } - return [ + const cell = [ - {HEADERS[fieldName]}: + {headers[fieldName]} , {content} ]; + if (arrayIndex % 2 === 0) { + rows.left.push(cell); + } else { + rows.right.push(cell); + } }); + return rows; } render() { + const { left, right } = this.buildRows(); + const additionalContent = this.getAdditionalContent(); return ( - - {this.buildRows()} - + + +

+ +

+
+ + + + + {left} + + + + + {right} + + + + { additionalContent } +
); } } diff --git a/x-pack/plugins/index_management/public/sections/index_list/components/index_actions_context_menu/index_actions_context_menu.container.js b/x-pack/plugins/index_management/public/sections/index_list/components/index_actions_context_menu/index_actions_context_menu.container.js index bbf6a5d4a9cea..babc94deef458 100644 --- a/x-pack/plugins/index_management/public/sections/index_list/components/index_actions_context_menu/index_actions_context_menu.container.js +++ b/x-pack/plugins/index_management/public/sections/index_list/components/index_actions_context_menu/index_actions_context_menu.container.js @@ -15,11 +15,14 @@ import { openIndices, editIndexSettings, refreshIndices, - openDetailPanel + openDetailPanel, + performExtensionAction, + reloadIndices } from '../../../../store/actions'; import { - getIndexStatusByIndexName + getIndexStatusByIndexName, + getIndicesByName } from '../../../../store/selectors'; const mapStateToProps = (state, ownProps) => { @@ -29,7 +32,8 @@ const mapStateToProps = (state, ownProps) => { indexStatusByName[indexName] = getIndexStatusByIndexName(state, indexName); }); return { - indexStatusByName + indexStatusByName, + indices: getIndicesByName(state, indexNames) }; }; @@ -73,6 +77,12 @@ const mapDispatchToProps = (dispatch, { indexNames }) => { }, deleteIndices: () => { dispatch(deleteIndices({ indexNames })); + }, + reloadIndices: () => { + dispatch(reloadIndices(indexNames)); + }, + performExtensionAction: (requestMethod, successMessage) => { + dispatch(performExtensionAction({ requestMethod, successMessage, indexNames })); } }; }; diff --git a/x-pack/plugins/index_management/public/sections/index_list/components/index_actions_context_menu/index_actions_context_menu.js b/x-pack/plugins/index_management/public/sections/index_list/components/index_actions_context_menu/index_actions_context_menu.js index 7c5339b53f75f..55593040a18ee 100644 --- a/x-pack/plugins/index_management/public/sections/index_list/components/index_actions_context_menu/index_actions_context_menu.js +++ b/x-pack/plugins/index_management/public/sections/index_list/components/index_actions_context_menu/index_actions_context_menu.js @@ -22,6 +22,8 @@ import { } from '@elastic/eui'; import { flattenPanelTree } from '../../../../lib/flatten_panel_tree'; import { INDEX_OPEN } from '../../../../../common/constants'; +import { getActionExtensions } from '../../../../index_management_extensions'; +import { getHttpClient } from '../../../../services/api'; class IndexActionsContextMenuUi extends Component { constructor(props) { @@ -29,9 +31,15 @@ class IndexActionsContextMenuUi extends Component { this.state = { isPopoverOpen: false, - showDeleteConfirmation: false + renderConfirmModal: null, }; } + closeConfirmModal = () => { + this.setState({ + renderConfirmModal: null + }); + this.props.resetSelection && this.props.resetSelection(); + } panels() { const { closeIndices, @@ -46,7 +54,10 @@ class IndexActionsContextMenuUi extends Component { detailPanel, indexNames, indexStatusByName, - intl + performExtensionAction, + indices, + intl, + reloadIndices } = this.props; const allOpen = all(indexNames, indexName => { return indexStatusByName[indexName] === INDEX_OPEN; @@ -118,7 +129,7 @@ class IndexActionsContextMenuUi extends Component { icon: , onClick: () => { this.closePopover(); - this.openForcemergeSegmentsModal(); + this.setState({ renderConfirmModal: this.renderForcemergeSegmentsModal }); } }); items.push({ @@ -171,7 +182,33 @@ class IndexActionsContextMenuUi extends Component { icon: , onClick: () => { this.closePopover(); - this.openDeleteConfirmationModal(); + this.setState({ renderConfirmModal: this.renderConfirmDeleteModal }); + } + }); + getActionExtensions().forEach((actionExtension) => { + const actionExtensionDefinition = actionExtension(indices, reloadIndices); + if (actionExtensionDefinition) { + const { buttonLabel, requestMethod, successMessage, icon, renderConfirmModal } = actionExtensionDefinition; + if (requestMethod) { + items.push({ + name: buttonLabel, + icon: , + onClick: () => { + this.closePopoverAndExecute(async () => { + await performExtensionAction(requestMethod, successMessage); + }); + }, + }); + } else { + items.push({ + name: buttonLabel, + icon: , + onClick: () => { + this.closePopover(); + this.setState({ renderConfirmModal }); + } + }); + } } }); items.forEach(item => { @@ -197,7 +234,7 @@ class IndexActionsContextMenuUi extends Component { closePopoverAndExecute = func => { this.setState({ isPopoverOpen: false, - showDeleteConfirmation: false + renderConfirmModal: false }); func(); this.props.resetSelection && this.props.resetSelection(); @@ -209,22 +246,6 @@ class IndexActionsContextMenuUi extends Component { }); }; - closeDeleteConfirmationModal = () => { - this.setState({ showDeleteConfirmation: false }); - }; - - openDeleteConfirmationModal = () => { - this.setState({ showDeleteConfirmation: true }); - }; - - openForcemergeSegmentsModal = () => { - this.setState({ showForcemergeSegmentsModal: true }); - }; - - closeForcemergeSegmentsModal = () => { - this.setState({ showForcemergeSegmentsModal: false }); - }; - forcemergeSegmentsError = () => { const { forcemergeSegments } = this.state; const { intl } = this.props; @@ -237,7 +258,7 @@ class IndexActionsContextMenuUi extends Component { }); } }; - forcemergeSegmentsModal = () => { + renderForcemergeSegmentsModal = () => { const { forcemergeIndices, indexNames, intl } = this.props; const helpText = intl.formatMessage({ id: 'xpack.idxMgmt.indexActionsMenu.forceMerge.forceMergeSegmentsHelpText', @@ -245,10 +266,6 @@ class IndexActionsContextMenuUi extends Component { }); const oneIndexSelected = this.oneIndexSelected(); const entity = this.getEntity(oneIndexSelected); - const { showForcemergeSegmentsModal } = this.state; - if (!showForcemergeSegmentsModal) { - return null; - } return ( { if (!this.forcemergeSegmentsError()) { this.closePopoverAndExecute(() => { @@ -341,14 +358,10 @@ class IndexActionsContextMenuUi extends Component { ); }; - confirmDeleteModal = () => { + renderConfirmDeleteModal = () => { const oneIndexSelected = this.oneIndexSelected(); const entity = this.getEntity(oneIndexSelected); const { deleteIndices, indexNames, intl } = this.props; - const { showDeleteConfirmation } = this.state; - if (!showDeleteConfirmation) { - return null; - } return ( this.closePopoverAndExecute(deleteIndices)} cancelButtonText={ intl.formatMessage({ @@ -417,7 +430,18 @@ class IndexActionsContextMenuUi extends Component { return this.props.indexNames.length === 1; }; getEntity = oneIndexSelected => { - return oneIndexSelected ? 'index' : 'indices'; + const { intl } = this.props; + return oneIndexSelected ? ( + intl.formatMessage({ + id: 'xpack.idxMgmt.indexActionsMenu.indexMessage', + defaultMessage: 'index' + }) + ) : ( + intl.formatMessage({ + id: 'xpack.idxMgmt.indexActionsMenu.indicesMessage', + defaultMessage: 'indices' + }) + ); }; render() { const { intl } = this.props; @@ -449,8 +473,7 @@ class IndexActionsContextMenuUi extends Component { return (
- {this.confirmDeleteModal()} - {this.forcemergeSegmentsModal()} + {this.state.renderConfirmModal ? this.state.renderConfirmModal(this.closeConfirmModal, getHttpClient()) : null} - +
); diff --git a/x-pack/plugins/index_management/public/sections/index_list/components/index_table/index_table.container.js b/x-pack/plugins/index_management/public/sections/index_list/components/index_table/index_table.container.js index 274935995dd69..91387f5aba5fa 100644 --- a/x-pack/plugins/index_management/public/sections/index_list/components/index_table/index_table.container.js +++ b/x-pack/plugins/index_management/public/sections/index_list/components/index_table/index_table.container.js @@ -13,7 +13,8 @@ import { isDetailPanelOpen, showSystemIndices, getSortField, - isSortAscending + isSortAscending, + getIndicesAsArray, } from '../../../../store/selectors'; import { filterChanged, @@ -29,6 +30,7 @@ import { IndexTable as PresentationComponent } from './index_table'; const mapStateToProps = (state) => { return { + allIndices: getIndicesAsArray(state), isDetailPanelOpen: isDetailPanelOpen(state), detailPanelIndexName: getDetailPanelIndexName(state), indices: getPageOfIndices(state), diff --git a/x-pack/plugins/index_management/public/sections/index_list/components/index_table/index_table.js b/x-pack/plugins/index_management/public/sections/index_list/components/index_table/index_table.js index df501ef98ff32..ef7649133aa21 100644 --- a/x-pack/plugins/index_management/public/sections/index_list/components/index_table/index_table.js +++ b/x-pack/plugins/index_management/public/sections/index_list/components/index_table/index_table.js @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component } from 'react'; -import { i18n } from '@kbn/i18n'; +import React, { Component, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; import { Route } from 'react-router-dom'; import { NoMatch } from '../../../no_match'; @@ -14,14 +14,15 @@ import { healthToColor } from '../../../../services'; import '../../../../styles/table.less'; import { + EuiCallOut, EuiHealth, EuiLink, EuiCheckbox, - EuiFieldSearch, EuiFlexGroup, EuiFlexItem, EuiPage, EuiSpacer, + EuiSearchBar, EuiSwitch, EuiTable, EuiTableBody, @@ -35,10 +36,11 @@ import { EuiTitle, EuiText, EuiPageBody, - EuiPageContent + EuiPageContent, } from '@elastic/eui'; import { IndexActionsContextMenu } from '../../components'; +import { getBannerExtensions, getFilterExtensions } from '../../../../index_management_extensions'; const HEADERS = { name: i18n.translate('xpack.idxMgmt.indexTable.headers.nameHeader', { @@ -62,9 +64,6 @@ const HEADERS = { size: i18n.translate('xpack.idxMgmt.indexTable.headers.storageSizeHeader', { defaultMessage: 'Storage size', }), - primary_size: i18n.translate('xpack.idxMgmt.indexTable.headers.primaryStorageSizeHeader', { - defaultMessage: 'Primary storage size', - }) }; export class IndexTableUi extends Component { @@ -90,17 +89,62 @@ export class IndexTableUi extends Component { super(props); this.state = { - selectedIndicesMap: {} + selectedIndicesMap: {}, }; } - + componentDidMount() { + const { + filterChanged, + filterFromURI + } = this.props; + if (filterFromURI) { + const decodedFilter = decodeURIComponent(filterFromURI); + filterChanged(EuiSearchBar.Query.parse(decodedFilter)); + } + } onSort = column => { const { sortField, isSortAscending, sortChanged } = this.props; const newIsSortAscending = sortField === column ? !isSortAscending : true; sortChanged(column, newIsSortAscending); }; - + renderFilterError() { + const { intl } = this.props; + const { filterError } = this.state; + if (!filterError) { + return; + } + return ( + + + + + ); + } + onFilterChanged = ({ query, error }) => { + if (error) { + this.setState({ filterError: error }); + } else { + this.props.filterChanged(query); + this.setState({ filterError: null }); + } + } + getFilters = () => { + const { allIndices } = this.props; + return getFilterExtensions().reduce((accum, filterExtension) => { + const filtersToAdd = filterExtension(allIndices); + return [...accum, ...filtersToAdd]; + }, []); + } toggleAll = () => { const allSelected = this.areAllItemsSelected(); if (allSelected) { @@ -112,7 +156,7 @@ export class IndexTableUi extends Component { selectedIndicesMap[name] = true; }); this.setState({ - selectedIndicesMap + selectedIndicesMap, }); }; @@ -125,7 +169,7 @@ export class IndexTableUi extends Component { newMap[name] = true; } return { - selectedIndicesMap: newMap + selectedIndicesMap: newMap, }; }); }; @@ -136,9 +180,7 @@ export class IndexTableUi extends Component { areAllItemsSelected = () => { const { indices } = this.props; - const indexOfUnselectedItem = indices.findIndex( - index => !this.isItemSelected(index.name) - ); + const indexOfUnselectedItem = indices.findIndex(index => !this.isItemSelected(index.name)); return indexOfUnselectedItem === -1; }; @@ -196,16 +238,50 @@ export class IndexTableUi extends Component { ); }); } + renderBanners() { + const { allIndices = [], filterChanged } = this.props; + return getBannerExtensions().map((bannerExtension, i) => { + const bannerData = bannerExtension(allIndices); + if (!bannerData) { + return null; + } + const { + type, + title, + message, + filter, + filterLabel, + } = bannerData; + + return ( + + + + {message} + {filter ? ( + filterChanged(filter)}> + {filterLabel} + + ) : null} + + + + + ); + }); + } buildRows() { const { indices = [], detailPanelIndexName } = this.props; return indices.map(index => { const { name } = index; return ( @@ -245,7 +321,6 @@ export class IndexTableUi extends Component { render() { const { - filterChanged, filter, showSystemIndices, showSystemIndicesChanged, @@ -254,7 +329,6 @@ export class IndexTableUi extends Component { } = this.props; const { selectedIndicesMap } = this.state; const atLeastOneItemSelected = Object.keys(selectedIndicesMap).length > 0; - return ( @@ -274,7 +348,7 @@ export class IndexTableUi extends Component {

@@ -284,14 +358,17 @@ export class IndexTableUi extends Component { id="checkboxShowSystemIndices" checked={showSystemIndices} onChange={event => showSystemIndicesChanged(event.target.checked)} - label={} + label={ + + } /> + {this.renderBanners()} {atLeastOneItemSelected ? ( @@ -309,29 +386,28 @@ export class IndexTableUi extends Component { ) : null} - { - filterChanged(event.target.value); - }} - data-test-subj="indexTableFilterInput" - placeholder={ - intl.formatMessage({ + - + {this.renderFilterError()} {indices.length > 0 ? ( diff --git a/x-pack/plugins/index_management/public/services/api.js b/x-pack/plugins/index_management/public/services/api.js index fdbf800ce180c..ec74e309847a0 100644 --- a/x-pack/plugins/index_management/public/services/api.js +++ b/x-pack/plugins/index_management/public/services/api.js @@ -9,6 +9,9 @@ let httpClient; export const setHttpClient = (client) => { httpClient = client; }; +export const getHttpClient = () => { + return httpClient; +}; const apiPrefix = chrome.addBasePath('/api/index_management'); export async function loadIndices() { diff --git a/x-pack/plugins/index_management/public/services/filter_items.js b/x-pack/plugins/index_management/public/services/filter_items.js index 6d2e3dae57f46..f523cb6105916 100644 --- a/x-pack/plugins/index_management/public/services/filter_items.js +++ b/x-pack/plugins/index_management/public/services/filter_items.js @@ -3,13 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { get } from 'lodash'; export const filterItems = (fields, filter = '', items = []) => { - const lowerFilter = filter.toLowerCase(); + const lowerFilter = filter.trim().toLowerCase(); return items.filter(item => { const actualFields = fields || Object.keys(item); const indexOfMatch = actualFields.findIndex(field => { - const normalizedField = String(item[field]).toLowerCase(); + const normalizedField = String(get(item, field)).toLowerCase(); return normalizedField.includes(lowerFilter); }); return indexOfMatch !== -1; diff --git a/x-pack/plugins/index_management/public/services/navigation.js b/x-pack/plugins/index_management/public/services/navigation.js new file mode 100644 index 0000000000000..1394ecf045c7c --- /dev/null +++ b/x-pack/plugins/index_management/public/services/navigation.js @@ -0,0 +1,16 @@ +/* + * 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 { BASE_PATH } from '../../common/constants'; +let urlService; +export const setUrlService = (aUrlService) => { + urlService = aUrlService; +}; +export const getUrlService = () => { + return urlService; +}; +export const getFilteredIndicesUri = (filter) => { + return encodeURI(`#${BASE_PATH}indices/filter/${encodeURIComponent(filter)}`); +}; \ No newline at end of file diff --git a/x-pack/plugins/index_management/public/store/actions/extension_action.js b/x-pack/plugins/index_management/public/store/actions/extension_action.js new file mode 100644 index 0000000000000..98ed6170f1b14 --- /dev/null +++ b/x-pack/plugins/index_management/public/store/actions/extension_action.js @@ -0,0 +1,20 @@ +/* + * 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 { reloadIndices } from '../actions'; +import { toastNotifications } from 'ui/notify'; +import { getHttpClient } from '../../services/api'; + +export const performExtensionAction = ({ requestMethod, indexNames, successMessage }) => async (dispatch) => { + try { + await requestMethod(indexNames, getHttpClient()); + } catch (error) { + toastNotifications.addDanger(error.data.message); + return; + } + dispatch(reloadIndices(indexNames)); + toastNotifications.addSuccess(successMessage); +}; diff --git a/x-pack/plugins/index_management/public/store/actions/index.js b/x-pack/plugins/index_management/public/store/actions/index.js index 86c6280e12bbf..a50854015adea 100644 --- a/x-pack/plugins/index_management/public/store/actions/index.js +++ b/x-pack/plugins/index_management/public/store/actions/index.js @@ -19,4 +19,5 @@ export * from './table_state'; export * from './edit_index_settings'; export * from './update_index_settings'; export * from './detail_panel'; +export * from './extension_action'; diff --git a/x-pack/plugins/index_management/public/store/selectors/index.js b/x-pack/plugins/index_management/public/store/selectors/index.js index 19a02908992e2..08e4cd9872157 100644 --- a/x-pack/plugins/index_management/public/store/selectors/index.js +++ b/x-pack/plugins/index_management/public/store/selectors/index.js @@ -3,12 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { Pager } from '@elastic/eui'; +import { Pager, EuiSearchBar } from '@elastic/eui'; import { createSelector } from 'reselect'; import { indexStatusLabels } from '../../lib/index_status_labels'; -import { filterItems, sortTable } from '../../services'; +import { sortTable } from '../../services'; export const getDetailPanelData = (state) => state.detailPanel.data; export const getDetailPanelError = (state) => state.detailPanel.error; @@ -16,6 +15,11 @@ export const getDetailPanelType = (state) => state.detailPanel.panelType; export const isDetailPanelOpen = (state) => !!getDetailPanelType(state); export const getDetailPanelIndexName = (state) => state.detailPanel.indexName; export const getIndices = (state) => state.indices.byId; +export const getIndicesAsArray = (state) => Object.values(state.indices.byId); +export const getIndicesByName = (state, indexNames) => { + const indices = getIndices(state); + return indexNames.map((indexName) => indices[indexName]); +}; export const getIndexByIndexName = (state, name) => getIndices(state)[name]; export const getFilteredIds = (state) => state.indices.filteredIds; export const getRowStatuses = (state) => state.rowStatus; @@ -27,6 +31,7 @@ export const getIndexStatusByIndexName = (state, indexName) => { const { status } = indices[indexName] || {}; return status; }; +const defaultFilterFields = ['name', 'uuid']; const getFilteredIndices = createSelector( getIndices, getRowStatuses, @@ -36,7 +41,8 @@ const getFilteredIndices = createSelector( const systemFilteredIndexes = tableState.showSystemIndices ? indexArray : indexArray.filter(index => !(index.name + '').startsWith('.')); - return filterItems(['name', 'uuid'], tableState.filter, systemFilteredIndexes); + const filter = tableState.filter || EuiSearchBar.Query.MATCH_ALL; + return EuiSearchBar.Query.execute(filter, systemFilteredIndexes, defaultFilterFields); } ); export const getTotalItems = createSelector( diff --git a/x-pack/plugins/index_management/public/styles/table.less b/x-pack/plugins/index_management/public/styles/table.less index 6ac08e744951d..10957c9458f3c 100644 --- a/x-pack/plugins/index_management/public/styles/table.less +++ b/x-pack/plugins/index_management/public/styles/table.less @@ -12,7 +12,7 @@ .indTable__horizontalScrollContainer { overflow-x: auto; max-width: 100%; - height: 100vh; + min-height: 100vh; } .indTable__horizontalScroll { min-width: 800px; diff --git a/x-pack/plugins/index_management/server/lib/enrich_response.js b/x-pack/plugins/index_management/server/lib/enrich_response.js new file mode 100644 index 0000000000000..1f502717b9d9a --- /dev/null +++ b/x-pack/plugins/index_management/server/lib/enrich_response.js @@ -0,0 +1,15 @@ +/* + * 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 { getIndexManagementDataEnrichers } from '../../index_management_data'; +export const enrichResponse = async (response, callWithRequest) => { + const dataEnrichers = getIndexManagementDataEnrichers(); + for (let i = 0; i < dataEnrichers.length; i++) { + const dataEnricher = dataEnrichers[i]; + response = await dataEnricher(response, callWithRequest); + } + return response; +}; \ No newline at end of file diff --git a/x-pack/plugins/index_management/server/routes/api/indices/register_list_route.js b/x-pack/plugins/index_management/server/routes/api/indices/register_list_route.js index fefb5fc91a843..7d580fd92e268 100644 --- a/x-pack/plugins/index_management/server/routes/api/indices/register_list_route.js +++ b/x-pack/plugins/index_management/server/routes/api/indices/register_list_route.js @@ -7,8 +7,9 @@ import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; +import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; import { fetchAliases } from './fetch_aliases'; +import { enrichResponse } from '../../../lib/enrich_response'; function formatHits(hits, aliases) { return hits.map(hit => { @@ -49,7 +50,8 @@ export function registerListRoute(server) { try { const aliases = await fetchAliases(callWithRequest); const hits = await fetchIndices(callWithRequest); - const response = formatHits(hits, aliases); + let response = formatHits(hits, aliases); + response = await enrichResponse(response, callWithRequest); return response; } catch (err) { if (isEsError(err)) { diff --git a/x-pack/plugins/index_management/server/routes/api/indices/register_reload_route.js b/x-pack/plugins/index_management/server/routes/api/indices/register_reload_route.js index bf2fe6c558867..3b39b2c56ae04 100644 --- a/x-pack/plugins/index_management/server/routes/api/indices/register_reload_route.js +++ b/x-pack/plugins/index_management/server/routes/api/indices/register_reload_route.js @@ -8,6 +8,7 @@ import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; import { licensePreRoutingFactory } from'../../../lib/license_pre_routing_factory'; +import { enrichResponse } from '../../../lib/enrich_response'; import { fetchAliases } from './fetch_aliases'; function getIndexNamesFromPayload(payload) { @@ -55,7 +56,8 @@ export function registerReloadRoute(server) { try { const indices = await fetchIndices(callWithRequest, indexNames); const aliases = await fetchAliases(callWithRequest); - const response = formatHits(indices, aliases); + let response = formatHits(indices, aliases); + response = await enrichResponse(response, callWithRequest); return response; } catch (err) { if (isEsError(err)) {