diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 3867c3065537..b0df3723ca77 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -14,8 +14,8 @@ import { SinonFakeServer } from 'sinon'; import { ReactWrapper } from 'enzyme'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; +import { createMemoryHistory } from 'history'; -import { init as initHttpRequests } from './helpers/http_requests'; import { notificationServiceMock, fatalErrorsServiceMock, @@ -41,8 +41,7 @@ import { policyNameAlreadyUsedErrorMessage, maximumDocumentsRequiredMessage, } from '../../public/application/services/policies/policy_validation'; -import { HttpResponse } from './helpers/http_requests'; -import { createMemoryHistory } from 'history'; +import { editPolicyHelpers } from './helpers'; // @ts-ignore initHttp(axios.create({ adapter: axiosXhrAdapter })); @@ -54,11 +53,8 @@ initNotification( const history = createMemoryHistory(); let server: SinonFakeServer; -let httpRequestsMockHelpers: { - setPoliciesResponse: (response: HttpResponse) => void; - setNodesListResponse: (response: HttpResponse) => void; - setNodesDetailsResponse: (nodeAttributes: string, response: HttpResponse) => void; -}; +let httpRequestsMockHelpers: editPolicyHelpers.EditPolicySetup['http']['httpRequestsMockHelpers']; +let http: editPolicyHelpers.EditPolicySetup['http']; const policy = { phases: { hot: { @@ -94,6 +90,17 @@ const activatePhase = async (rendered: ReactWrapper, phase: string) => { }); rendered.update(); }; +const openNodeAttributesSection = (rendered: ReactWrapper, phase: string) => { + const getControls = () => findTestSubject(rendered, `${phase}-dataTierAllocationControls`); + act(() => { + findTestSubject(getControls(), 'dataTierSelect').simulate('click'); + }); + rendered.update(); + act(() => { + findTestSubject(getControls(), 'customDataAllocationOption').simulate('click'); + }); + rendered.update(); +}; const expectedErrorMessages = (rendered: ReactWrapper, expectedMessages: string[]) => { const errorMessages = rendered.find('.euiFormErrorText'); expect(errorMessages.length).toBe(expectedMessages.length); @@ -119,12 +126,16 @@ const setPolicyName = (rendered: ReactWrapper, policyName: string) => { policyNameField.simulate('change', { target: { value: policyName } }); rendered.update(); }; -const setPhaseAfter = (rendered: ReactWrapper, phase: string, after: string) => { +const setPhaseAfter = (rendered: ReactWrapper, phase: string, after: string | number) => { const afterInput = rendered.find(`input#${phase}-selectedMinimumAge`); afterInput.simulate('change', { target: { value: after } }); rendered.update(); }; -const setPhaseIndexPriority = (rendered: ReactWrapper, phase: string, priority: string) => { +const setPhaseIndexPriority = ( + rendered: ReactWrapper, + phase: string, + priority: string | number +) => { const priorityInput = rendered.find(`input#${phase}-phaseIndexPriority`); priorityInput.simulate('change', { target: { value: priority } }); rendered.update(); @@ -139,7 +150,9 @@ describe('edit policy', () => { component = ( ); - ({ server, httpRequestsMockHelpers } = initHttpRequests()); + + ({ http } = editPolicyHelpers.setup()); + ({ server, httpRequestsMockHelpers } = http); httpRequestsMockHelpers.setPoliciesResponse(policies); }); @@ -321,7 +334,7 @@ describe('edit policy', () => { describe('warm phase', () => { beforeEach(() => { server.respondImmediately = true; - httpRequestsMockHelpers.setNodesListResponse({}); + http.setupNodeListResponse(); httpRequestsMockHelpers.setNodesDetailsResponse('attribute:true', [ { nodeId: 'testNodeId', stats: { name: 'testNodeName', host: 'testHost' } }, ]); @@ -431,34 +444,39 @@ describe('edit policy', () => { expect(getNodeAttributeSelect(rendered, 'warm').exists()).toBeFalsy(); }); test('should show warning instead of node attributes input when none exist', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: { data: ['node1'] }, + }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeTruthy(); + openNodeAttributesSection(rendered, 'warm'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeTruthy(); expect(getNodeAttributeSelect(rendered, 'warm').exists()).toBeFalsy(); }); test('should show node attributes input when attributes exist', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'warm'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').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', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'warm'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'warm'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(findTestSubject(rendered, 'warm-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); @@ -473,11 +491,23 @@ describe('edit policy', () => { rendered.update(); expect(rendered.find('.euiFlyout').exists()).toBeTruthy(); }); + test('should show default allocation warning when no node roles are found', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: {}, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'warm'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); + }); }); describe('cold phase', () => { beforeEach(() => { server.respondImmediately = true; - httpRequestsMockHelpers.setNodesListResponse({}); + http.setupNodeListResponse(); httpRequestsMockHelpers.setNodesDetailsResponse('attribute:true', [ { nodeId: 'testNodeId', stats: { name: 'testNodeName', host: 'testHost' } }, ]); @@ -511,34 +541,39 @@ describe('edit policy', () => { expect(getNodeAttributeSelect(rendered, 'cold').exists()).toBeFalsy(); }); test('should show warning instead of node attributes input when none exist', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: { data: ['node1'] }, + }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeTruthy(); + openNodeAttributesSection(rendered, 'cold'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeTruthy(); expect(getNodeAttributeSelect(rendered, 'cold').exists()).toBeFalsy(); }); test('should show node attributes input when attributes exist', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'cold'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').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', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'cold'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'cold'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(findTestSubject(rendered, 'cold-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); @@ -563,6 +598,128 @@ describe('edit policy', () => { save(rendered); expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); }); + test('should show default allocation warning when no node roles are found', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: {}, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'cold'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); + }); + }); + describe('frozen phase', () => { + beforeEach(() => { + server.respondImmediately = true; + http.setupNodeListResponse(); + httpRequestsMockHelpers.setNodesDetailsResponse('attribute:true', [ + { nodeId: 'testNodeId', stats: { name: 'testNodeName', host: 'testHost' } }, + ]); + }); + test('should allow 0 for phase timing', async () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + setPhaseAfter(rendered, 'frozen', 0); + save(rendered); + expectedErrorMessages(rendered, []); + }); + test('should show positive number required error when trying to save cold phase with -1 for after', async () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + setPhaseAfter(rendered, 'frozen', -1); + save(rendered); + expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + }); + test('should show spinner for node attributes input when loading', async () => { + server.respondImmediately = false; + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + expect(getNodeAttributeSelect(rendered, 'frozen').exists()).toBeFalsy(); + }); + test('should show warning instead of node attributes input when none exist', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: { data: ['node1'] }, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'frozen'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeTruthy(); + expect(getNodeAttributeSelect(rendered, 'frozen').exists()).toBeFalsy(); + }); + test('should show node attributes input when attributes exist', async () => { + http.setupNodeListResponse(); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'frozen'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); + const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'frozen'); + 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', async () => { + http.setupNodeListResponse(); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'frozen'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); + const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'frozen'); + expect(nodeAttributesSelect.exists()).toBeTruthy(); + expect(findTestSubject(rendered, 'frozen-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); + expect(nodeAttributesSelect.find('option').length).toBe(2); + nodeAttributesSelect.simulate('change', { target: { value: 'attribute:true' } }); + rendered.update(); + const flyoutButton = findTestSubject(rendered, 'frozen-viewNodeDetailsFlyoutButton'); + expect(flyoutButton.exists()).toBeTruthy(); + await act(async () => { + await flyoutButton.simulate('click'); + }); + rendered.update(); + expect(rendered.find('.euiFlyout').exists()).toBeTruthy(); + }); + test('should show positive number required error when trying to save with -1 for index priority', async () => { + http.setupNodeListResponse(); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + setPhaseAfter(rendered, 'frozen', 1); + setPhaseIndexPriority(rendered, 'frozen', -1); + save(rendered); + expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + }); + test('should show default allocation warning when no node roles are found', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: {}, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); + }); }); describe('delete phase', () => { test('should allow 0 for phase timing', async () => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/edit_policy.ts b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/edit_policy.ts new file mode 100644 index 000000000000..4eeb542671d2 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/edit_policy.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { init as initHttpRequests } from './http_requests'; + +export type EditPolicySetup = ReturnType; + +export const setup = () => { + const { httpRequestsMockHelpers, server } = initHttpRequests(); + + const setupNodeListResponse = ( + response: Record = { + nodesByAttributes: { 'attribute:true': ['node1'] }, + nodesByRoles: { data: ['node1'] }, + } + ) => { + httpRequestsMockHelpers.setNodesListResponse(response); + }; + + return { + http: { + setupNodeListResponse, + httpRequestsMockHelpers, + server, + }, + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts index 6cbe3bdf1f8c..a9d326073e4d 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts @@ -40,6 +40,8 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; }; +export type HttpRequestMockHelpers = ReturnType; + export const init = () => { const server = sinon.fakeServer.create(); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/index.ts b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/index.ts new file mode 100644 index 000000000000..4c32ea121bb5 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/index.ts @@ -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 * as editPolicyHelpers from './edit_policy'; + +export { HttpRequestMockHelpers, init } from './http_requests'; + +export { editPolicyHelpers }; diff --git a/x-pack/plugins/index_lifecycle_management/common/types/api.ts b/x-pack/plugins/index_lifecycle_management/common/types/api.ts new file mode 100644 index 000000000000..16b8fbd127ab --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/common/types/api.ts @@ -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 type NodeDataRole = 'data' | 'data_hot' | 'data_warm' | 'data_cold' | 'data_frozen'; + +export interface ListNodesRouteResponse { + nodesByAttributes: { [attributePair: string]: string[] }; + nodesByRoles: { [role in NodeDataRole]?: string[] }; +} diff --git a/x-pack/plugins/index_lifecycle_management/common/types/index.ts b/x-pack/plugins/index_lifecycle_management/common/types/index.ts index fef79c7782bb..a23dc647f1f6 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/index.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './api'; + export * from './policies'; diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index 97effee44533..8f913dd884df 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -6,6 +6,8 @@ import { Index as IndexInterface } from '../../../index_management/common/types'; +export type PhaseWithAllocation = 'warm' | 'cold' | 'frozen'; + export interface SerializedPolicy { name: string; phases: Phases; @@ -62,6 +64,7 @@ export interface SerializedWarmPhase extends SerializedPhase { set_priority?: { priority: number | null; }; + migrate?: { enabled: boolean }; }; } @@ -72,6 +75,7 @@ export interface SerializedColdPhase extends SerializedPhase { set_priority?: { priority: number | null; }; + migrate?: { enabled: boolean }; }; } @@ -82,6 +86,7 @@ export interface SerializedFrozenPhase extends SerializedPhase { set_priority?: { priority: number | null; }; + migrate?: { enabled: boolean }; }; } @@ -103,6 +108,13 @@ export interface AllocateAction { require?: { [attribute: string]: string; }; + migrate?: { + /** + * If enabled is ever set it will only be set to `false` because the default value + * for this is `true`. Rather leave unspecified for true. + */ + enabled: false; + }; } export interface Policy { @@ -125,9 +137,23 @@ export interface PhaseWithMinAge { selectedMinimumAgeUnits: string; } +/** + * Different types of allocation markers we use in deserialized policies. + * + * default - use data tier based data allocation based on node roles -- this is ES best practice mode. + * custom - use node_attrs to allocate data to specific nodes + * none - do not move data anywhere when entering a phase + */ +export type DataTierAllocationType = 'default' | 'custom' | 'none'; + export interface PhaseWithAllocationAction { selectedNodeAttrs: string; selectedReplicaCount: string; + /** + * A string value indicating allocation type. If unspecified we assume the user + * wants to use default allocation. + */ + dataTierAllocationType: DataTierAllocationType; } export interface PhaseWithIndexPriority { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts index f11860d36faf..6d4c57d23138 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts @@ -38,6 +38,7 @@ export const defaultNewWarmPhase: WarmPhase = { selectedReplicaCount: '', warmPhaseOnRollover: true, phaseIndexPriority: '50', + dataTierAllocationType: 'default', }; export const defaultNewColdPhase: ColdPhase = { @@ -48,6 +49,7 @@ export const defaultNewColdPhase: ColdPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '0', + dataTierAllocationType: 'default', }; export const defaultNewFrozenPhase: FrozenPhase = { @@ -58,6 +60,7 @@ export const defaultNewFrozenPhase: FrozenPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '0', + dataTierAllocationType: 'default', }; export const defaultNewDeletePhase: DeletePhase = { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/check_phase_compatibility.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/check_phase_compatibility.ts new file mode 100644 index 000000000000..2ef0fb145551 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/check_phase_compatibility.ts @@ -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 { + NodeDataRole, + ListNodesRouteResponse, + PhaseWithAllocation, +} from '../../../../common/types'; + +/** + * Given a phase and current node roles, determine whether the phase + * can use default data tier allocation. + * + * This can only be checked for phases that have an allocate action. + */ +export const isPhaseDefaultDataAllocationCompatible = ( + phase: PhaseWithAllocation, + nodesByRoles: ListNodesRouteResponse['nodesByRoles'] +): boolean => { + // The 'data' role covers all node roles, so if we have at least one node with the data role + // we can use default allocation. + if (nodesByRoles.data?.length) { + return true; + } + + // Otherwise we need to check whether a node role for the specific phase exists + if (nodesByRoles[`data_${phase}` as NodeDataRole]?.length) { + return true; + } + + // Otherwise default allocation has nowhere to allocate new shards to in this phase. + return false; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/determine_allocation_type.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/determine_allocation_type.ts new file mode 100644 index 000000000000..4067ad97fc43 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/determine_allocation_type.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DataTierAllocationType, AllocateAction } from '../../../../common/types'; + +/** + * Determine what deserialized state the policy config represents. + * + * See {@DataTierAllocationType} for more information. + */ +export const determineDataTierAllocationType = ( + allocateAction?: AllocateAction +): DataTierAllocationType => { + if (!allocateAction) { + return 'default'; + } + + if (allocateAction.migrate?.enabled === false) { + return 'none'; + } + + if ( + (allocateAction.require && Object.keys(allocateAction.require).length) || + (allocateAction.include && Object.keys(allocateAction.include).length) || + (allocateAction.exclude && Object.keys(allocateAction.exclude).length) + ) { + return 'custom'; + } + + return 'default'; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/index.ts new file mode 100644 index 000000000000..67a512cefe00 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/index.ts @@ -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 * from './determine_allocation_type'; + +export * from './check_phase_compatibility'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts new file mode 100644 index 000000000000..1dabae1a0f0c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts @@ -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 * from './data_tiers'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.scss new file mode 100644 index 000000000000..62ec3f303e1e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.scss @@ -0,0 +1,9 @@ +.indexLifecycleManagement__phase__dataTierAllocation { + &__controlSection { + background-color: $euiColorLightestShade; + padding-top: $euiSizeM; + padding-left: $euiSizeM; + padding-right: $euiSizeM; + padding-bottom: $euiSizeM; + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.tsx new file mode 100644 index 000000000000..3ae60a5a3d62 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.tsx @@ -0,0 +1,194 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiText, EuiFormRow, EuiSpacer, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui'; + +import { DataTierAllocationType } from '../../../../../../common/types'; +import { NodeAllocation } from './node_allocation'; +import { SharedProps } from './types'; + +import './data_tier_allocation.scss'; + +type SelectOptions = EuiSuperSelectOption; + +const i18nTexts = { + allocationFieldLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.allocationFieldLabel', + { defaultMessage: 'Data tier options' } + ), + allocationOptions: { + warm: { + default: { + input: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.defaultOption.input', + { defaultMessage: 'Use warm nodes (recommended)' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.defaultOption.helpText', + { defaultMessage: 'Move data to nodes in the warm tier.' } + ), + }, + none: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.noneOption.input', + { defaultMessage: 'Off' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.noneOption.helpText', + { defaultMessage: 'Do not move data in the warm phase.' } + ), + }, + custom: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.customOption.input', + { defaultMessage: 'Custom' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.customOption.helpText', + { defaultMessage: 'Move data based on node attributes.' } + ), + }, + }, + cold: { + default: { + input: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.defaultOption.input', + { defaultMessage: 'Use cold nodes (recommended)' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.defaultOption.helpText', + { defaultMessage: 'Move data to nodes in the cold tier.' } + ), + }, + none: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.noneOption.input', + { defaultMessage: 'Off' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.noneOption.helpText', + { defaultMessage: 'Do not move data in the cold phase.' } + ), + }, + custom: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.input', + { defaultMessage: 'Custom' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.helpText', + { defaultMessage: 'Move data based on node attributes.' } + ), + }, + }, + frozen: { + default: { + input: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.defaultOption.input', + { defaultMessage: 'Use frozen nodes (recommended)' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.defaultOption.helpText', + { defaultMessage: 'Move data to nodes in the frozen tier.' } + ), + }, + none: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.noneOption.input', + { defaultMessage: 'Off' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.noneOption.helpText', + { defaultMessage: 'Do not move data in the frozen phase.' } + ), + }, + custom: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.customOption.input', + { defaultMessage: 'Custom' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.customOption.helpText', + { defaultMessage: 'Move data based on node attributes.' } + ), + }, + }, + }, +}; + +export const DataTierAllocation: FunctionComponent = (props) => { + const { phaseData, setPhaseData, phase, hasNodeAttributes } = props; + + return ( +
+ + setPhaseData('dataTierAllocationType', value)} + options={ + [ + { + value: 'default', + inputDisplay: i18nTexts.allocationOptions[phase].default.input, + dropdownDisplay: ( + <> + {i18nTexts.allocationOptions[phase].default.input} + +

+ {i18nTexts.allocationOptions[phase].default.helpText} +

+
+ + ), + }, + { + value: 'none', + inputDisplay: i18nTexts.allocationOptions[phase].none.inputDisplay, + dropdownDisplay: ( + <> + {i18nTexts.allocationOptions[phase].none.inputDisplay} + +

+ {i18nTexts.allocationOptions[phase].none.helpText} +

+
+ + ), + }, + { + 'data-test-subj': 'customDataAllocationOption', + value: 'custom', + inputDisplay: i18nTexts.allocationOptions[phase].custom.inputDisplay, + dropdownDisplay: ( + <> + {i18nTexts.allocationOptions[phase].custom.inputDisplay} + +

+ {i18nTexts.allocationOptions[phase].custom.helpText} +

+
+ + ), + }, + ] as SelectOptions[] + } + /> +
+ {phaseData.dataTierAllocationType === 'custom' && hasNodeAttributes && ( + <> + +
+ +
+ + )} +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/default_allocation_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/default_allocation_warning.tsx new file mode 100644 index 000000000000..a7ebc0d2e4a2 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/default_allocation_warning.tsx @@ -0,0 +1,72 @@ +/* + * 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 React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; + +import { PhaseWithAllocation } from '../../../../../../common/types'; + +const i18nTexts = { + warm: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the warm tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the warm tier to use role-based allocation. The policy will fail to complete allocation if there are no warm nodes.', + } + ), + }, + cold: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the cold tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the cold tier to use role-based allocation. The policy will fail to complete allocation if there are no cold nodes.', + } + ), + }, + frozen: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.frozenPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the frozen tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.frozenPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the frozen tier to use role-based allocation. The policy will fail to complete allocation if there are no frozen nodes.', + } + ), + }, +}; + +interface Props { + phase: PhaseWithAllocation; +} + +export const DefaultAllocationWarning: FunctionComponent = ({ phase }) => { + return ( + <> + + + {i18nTexts[phase].body} + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/index.ts new file mode 100644 index 000000000000..26464a75ae14 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/index.ts @@ -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 { NodesDataProvider } from './node_data_provider'; +export { NodeAllocation } from './node_allocation'; +export { NodeAttrsDetails } from './node_attrs_details'; +export { DataTierAllocation } from './data_tier_allocation'; +export { DefaultAllocationWarning } from './default_allocation_warning'; +export { NoNodeAttributesWarning } from './no_node_attributes_warning'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/no_node_attributes_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/no_node_attributes_warning.tsx new file mode 100644 index 000000000000..1ba82623c2b9 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/no_node_attributes_warning.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { PhaseWithAllocation } from '../../../../../../common/types'; + +const i18nTexts = { + title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingLabel', { + defaultMessage: 'No custom node attributes configured', + }), + warm: { + body: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.warm.nodeAttributesMissingDescription', + { + defaultMessage: + 'Define custom node attributes in elasticsearch.yml to use attribute-based allocation. Warm nodes will be used instead.', + } + ), + }, + cold: { + body: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cold.nodeAttributesMissingDescription', + { + defaultMessage: + 'Define custom node attributes in elasticsearch.yml to use attribute-based allocation. Cold nodes will be used instead.', + } + ), + }, + frozen: { + body: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.frozen.nodeAttributesMissingDescription', + { + defaultMessage: + 'Define custom node attributes in elasticsearch.yml to use attribute-based allocation. Frozen nodes will be used instead.', + } + ), + }, +}; + +export const NoNodeAttributesWarning: FunctionComponent<{ phase: PhaseWithAllocation }> = ({ + phase, +}) => { + return ( + <> + + + {i18nTexts[phase].body} + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_allocation.tsx new file mode 100644 index 000000000000..a57a6ba4ff2c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_allocation.tsx @@ -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, { useState, FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiSelect, EuiButtonEmpty, EuiText, EuiSpacer } from '@elastic/eui'; + +import { PhaseWithAllocationAction } from '../../../../../../common/types'; +import { propertyof } from '../../../../services/policies/policy_validation'; + +import { ErrableFormRow } from '../form_errors'; + +import { NodeAttrsDetails } from './node_attrs_details'; +import { SharedProps } from './types'; +import { LearnMoreLink } from '../learn_more_link'; + +const learnMoreLink = ( + + } + docPath="modules-cluster.html#cluster-shard-allocation-settings" + /> +); + +const i18nTexts = { + doNotModifyAllocationOption: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.nodeAllocation.doNotModifyAllocationOption', + { defaultMessage: 'Do not modify allocation configuration' } + ), +}; + +export const NodeAllocation: FunctionComponent = ({ + phase, + setPhaseData, + errors, + phaseData, + isShowingErrors, + nodes, +}) => { + const [selectedNodeAttrsForDetails, setSelectedNodeAttrsForDetails] = useState( + null + ); + + const nodeOptions = Object.keys(nodes).map((attrs) => ({ + text: `${attrs} (${nodes[attrs].length})`, + value: attrs, + })); + + nodeOptions.sort((a, b) => a.value.localeCompare(b.value)); + + // check that this string is a valid property + const nodeAttrsProperty = propertyof('selectedNodeAttrs'); + + return ( + <> + +

+ +

+
+ + + {/* + TODO: this field component must be revisited to support setting multiple require values and to support + setting `include and exclude values on ILM policies. See https://github.com/elastic/kibana/issues/77344 + */} + setSelectedNodeAttrsForDetails(phaseData.selectedNodeAttrs)} + > + + + ) : null + } + > + { + setPhaseData(nodeAttrsProperty, e.target.value); + }} + /> + + + {selectedNodeAttrsForDetails ? ( + setSelectedNodeAttrsForDetails(null)} + /> + ) : null} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_attrs_details.tsx similarity index 97% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_attrs_details.tsx index af8833c8082b..c29495d13eb8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_attrs_details.tsx @@ -20,7 +20,7 @@ import { EuiButton, } from '@elastic/eui'; -import { useLoadNodeDetails } from '../../../services/api'; +import { useLoadNodeDetails } from '../../../../services/api'; interface Props { close: () => void; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_data_provider.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_data_provider.tsx new file mode 100644 index 000000000000..a7c0f3ec7c86 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_data_provider.tsx @@ -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 { EuiButton, EuiCallOut, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ListNodesRouteResponse } from '../../../../../../common/types'; +import { useLoadNodes } from '../../../../services/api'; + +interface Props { + children: (data: ListNodesRouteResponse) => JSX.Element; +} + +export const NodesDataProvider = ({ children }: Props): JSX.Element => { + const { isLoading, data, error, resendRequest } = useLoadNodes(); + + if (isLoading) { + return ( + <> + + + + ); + } + + const renderError = () => { + if (error) { + const { statusCode, message } = error; + return ( + <> + + } + color="danger" + > +

+ {message} ({statusCode}) +

+ + + +
+ + + + ); + } + return null; + }; + + return ( + <> + {renderError()} + {/* `data` will always be defined because we use an initial value when loading */} + {children(data!)} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/types.ts new file mode 100644 index 000000000000..d4cb31a3be9e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/types.ts @@ -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 { + ListNodesRouteResponse, + PhaseWithAllocation, + PhaseWithAllocationAction, +} from '../../../../../../common/types'; +import { PhaseValidationErrors } from '../../../../services/policies/policy_validation'; + +export interface SharedProps { + phase: PhaseWithAllocation; + errors?: PhaseValidationErrors; + phaseData: PhaseWithAllocationAction; + setPhaseData: (dataKey: keyof PhaseWithAllocationAction, value: string) => void; + isShowingErrors: boolean; + nodes: ListNodesRouteResponse['nodesByAttributes']; + hasNodeAttributes: boolean; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_field.tsx new file mode 100644 index 000000000000..7bf8cd3ba6d9 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_field.tsx @@ -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 React, { FunctionComponent } from 'react'; +import { EuiDescribedFormGroup, EuiDescribedFormGroupProps } from '@elastic/eui'; + +import { ToggleableField, Props as ToggleableFieldProps } from './toggleable_field'; + +type Props = EuiDescribedFormGroupProps & { + switchProps: ToggleableFieldProps; +}; + +export const DescribedFormField: FunctionComponent = ({ + children, + switchProps, + ...restDescribedFormProps +}) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index 4410c4bb3839..2428cade0898 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -8,11 +8,17 @@ export { ActiveBadge } from './active_badge'; export { ErrableFormRow } from './form_errors'; export { LearnMoreLink } from './learn_more_link'; export { MinAgeInput } from './min_age_input'; -export { NodeAllocation } from './node_allocation'; -export { NodeAttrsDetails } from './node_attrs_details'; export { OptionalLabel } from './optional_label'; export { PhaseErrorMessage } from './phase_error_message'; export { PolicyJsonFlyout } from './policy_json_flyout'; export { SetPriorityInput } from './set_priority_input'; export { SnapshotPolicies } from './snapshot_policies'; +export { + DataTierAllocation, + NodeAllocation, + NodeAttrsDetails, + NodesDataProvider, + DefaultAllocationWarning, +} from './data_tier_allocation'; +export { DescribedFormField } from './described_form_field'; export { Forcemerge } from './forcemerge'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx deleted file mode 100644 index 6a22d8716514..000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { - EuiSelect, - EuiButtonEmpty, - EuiCallOut, - EuiSpacer, - EuiLoadingSpinner, - EuiButton, -} from '@elastic/eui'; - -import { LearnMoreLink } from './learn_more_link'; -import { ErrableFormRow } from './form_errors'; -import { useLoadNodes } from '../../../services/api'; -import { NodeAttrsDetails } from './node_attrs_details'; -import { PhaseWithAllocationAction, Phases } from '../../../../../common/types'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; - -const learnMoreLink = ( - - - - } - docPath="modules-cluster.html#cluster-shard-allocation-settings" - /> - -); - -interface Props { - phase: keyof Phases & string; - errors?: PhaseValidationErrors; - phaseData: T; - setPhaseData: (dataKey: keyof T & string, value: string) => void; - isShowingErrors: boolean; -} -export const NodeAllocation = ({ - phase, - setPhaseData, - errors, - phaseData, - isShowingErrors, -}: React.PropsWithChildren>) => { - const { isLoading, data: nodes, error, resendRequest } = useLoadNodes(); - - const [selectedNodeAttrsForDetails, setSelectedNodeAttrsForDetails] = useState( - null - ); - - if (isLoading) { - return ( - - - - - ); - } - - if (error) { - const { statusCode, message } = error; - return ( - - - } - color="danger" - > -

- {message} ({statusCode}) -

- - - -
- - -
- ); - } - - let nodeOptions = Object.keys(nodes).map((attrs) => ({ - text: `${attrs} (${nodes[attrs].length})`, - value: attrs, - })); - - nodeOptions.sort((a, b) => a.value.localeCompare(b.value)); - if (nodeOptions.length) { - nodeOptions = [ - { - text: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.defaultNodeAllocation', { - defaultMessage: "Default allocation (don't use attributes)", - }), - value: '', - }, - ...nodeOptions, - ]; - } - if (!nodeOptions.length) { - return ( - - - } - color="warning" - > - - {learnMoreLink} - - - - - ); - } - - // check that this string is a valid property - const nodeAttrsProperty = propertyof('selectedNodeAttrs'); - - return ( - - - { - setPhaseData(nodeAttrsProperty, e.target.value); - }} - /> - - {!!phaseData.selectedNodeAttrs ? ( - setSelectedNodeAttrsForDetails(phaseData.selectedNodeAttrs)} - > - - - ) : null} - {learnMoreLink} - - - {selectedNodeAttrsForDetails ? ( - setSelectedNodeAttrsForDetails(null)} - /> - ) : null} - - ); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx new file mode 100644 index 000000000000..ff4301808db3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx @@ -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 React, { FunctionComponent, useState } from 'react'; +import { EuiSpacer, EuiSwitch, EuiSwitchProps } from '@elastic/eui'; + +export interface Props extends Omit { + initialValue: boolean; + onChange: (nextValue: boolean) => void; +} + +export const ToggleableField: FunctionComponent = ({ + initialValue, + onChange, + children, + ...restProps +}) => { + const [isContentVisible, setIsContentVisible] = useState(initialValue); + + return ( + <> + { + const nextValue = e.target.checked; + setIsContentVisible(nextValue); + onChange(nextValue); + }} + /> + + {isContentVisible ? children : null} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index f1c287788e08..85529ef0c9a5 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useState, useCallback } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -45,7 +45,7 @@ import { import { ErrableFormRow, LearnMoreLink, PolicyJsonFlyout } from './components'; import { ColdPhase, DeletePhase, FrozenPhase, HotPhase, WarmPhase } from './phases'; -interface Props { +export interface Props { policies: PolicyFromES[]; policyName: string; getUrlForApp: ( @@ -119,15 +119,39 @@ export const EditPolicy: React.FunctionComponent = ({ setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); }; - const setPhaseData = (phase: keyof Phases, key: string, value: any) => { - setPolicy({ - ...policy, - phases: { - ...policy.phases, - [phase]: { ...policy.phases[phase], [key]: value }, - }, - }); - }; + const setPhaseData = useCallback( + (phase: keyof Phases, key: string, value: any) => { + setPolicy((nextPolicy) => ({ + ...nextPolicy, + phases: { + ...nextPolicy.phases, + [phase]: { ...nextPolicy.phases[phase], [key]: value }, + }, + })); + }, + [setPolicy] + ); + + const setHotPhaseData = useCallback( + (key: string, value: any) => setPhaseData('hot', key, value), + [setPhaseData] + ); + const setWarmPhaseData = useCallback( + (key: string, value: any) => setPhaseData('warm', key, value), + [setPhaseData] + ); + const setColdPhaseData = useCallback( + (key: string, value: any) => setPhaseData('cold', key, value), + [setPhaseData] + ); + const setFrozenPhaseData = useCallback( + (key: string, value: any) => setPhaseData('frozen', key, value), + [setPhaseData] + ); + const setDeletePhaseData = useCallback( + (key: string, value: any) => setPhaseData('delete', key, value), + [setPhaseData] + ); const setWarmPhaseOnRollover = (value: boolean) => { setPolicy({ @@ -277,7 +301,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('hot', key, value)} + setPhaseData={setHotPhaseData} phaseData={policy.phases.hot} setWarmPhaseOnRollover={setWarmPhaseOnRollover} /> @@ -287,7 +311,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('warm', key, value)} + setPhaseData={setWarmPhaseData} phaseData={policy.phases.warm} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> @@ -297,7 +321,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('cold', key, value)} + setPhaseData={setColdPhaseData} phaseData={policy.phases.cold} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> @@ -307,7 +331,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('frozen', key, value)} + setPhaseData={setFrozenPhaseData} phaseData={policy.phases.frozen} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> @@ -318,7 +342,7 @@ export const EditPolicy: React.FunctionComponent = ({ errors={errors?.delete} isShowingErrors={isShowingErrors && !!errors && Object.keys(errors.delete).length > 0} getUrlForApp={getUrlForApp} - setPhaseData={(key, value) => setPhaseData('delete', key, value)} + setPhaseData={setDeletePhaseData} phaseData={policy.phases.delete} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx index ae2858e7a84a..241a98fffa6d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx @@ -4,19 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { FunctionComponent, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFieldNumber, - EuiDescribedFormGroup, - EuiSwitch, - EuiTextColor, -} from '@elastic/eui'; +import { EuiFieldNumber, EuiDescribedFormGroup, EuiSwitch, EuiTextColor } from '@elastic/eui'; import { ColdPhase as ColdPhaseInterface, Phases } from '../../../../../common/types'; import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; @@ -27,14 +19,24 @@ import { PhaseErrorMessage, OptionalLabel, ErrableFormRow, - MinAgeInput, - NodeAllocation, SetPriorityInput, + MinAgeInput, + DescribedFormField, } from '../components'; -const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { - defaultMessage: 'Freeze index', -}); +import { DataTierAllocationField } from './shared'; + +const i18nTexts = { + freezeLabel: i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { + defaultMessage: 'Freeze index', + }), + dataTierAllocation: { + description: i18n.translate('xpack.indexLifecycleMgmt.coldPhase.dataTier.description', { + defaultMessage: + 'Move data to data nodes optimized for less frequent, read-only access. Store cold data on less-expensive hardware.', + }), + }, +}; const coldProperty: keyof Phases = 'cold'; const phaseProperty = (propertyName: keyof ColdPhaseInterface) => propertyName; @@ -46,18 +48,17 @@ interface Props { errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } -export class ColdPhase extends PureComponent { - render() { - const { - setPhaseData, - phaseData, - errors, - isShowingErrors, - hotPhaseRolloverEnabled, - } = this.props; - - return ( -
+export const ColdPhase: FunctionComponent = ({ + setPhaseData, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, +}) => { + return ( +
+ <> + {/* Section title group; containing min age */} @@ -86,7 +87,7 @@ export class ColdPhase extends PureComponent { data-test-subj="enablePhaseSwitch-cold" label={ } @@ -101,68 +102,83 @@ export class ColdPhase extends PureComponent { } fullWidth > - - {phaseData.phaseEnabled ? ( - - - errors={errors} - phaseData={phaseData} - phase={coldProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - rolloverEnabled={hotPhaseRolloverEnabled} - /> - - - - phase={coldProperty} - setPhaseData={setPhaseData} - errors={errors} - phaseData={phaseData} - isShowingErrors={isShowingErrors} - /> - - - - - - - - } - isShowingErrors={isShowingErrors} - errors={errors?.freezeEnabled} - helpText={i18n.translate( - 'xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText', - { - defaultMessage: 'By default, the number of replicas remains the same.', - } - )} - > - { - setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); - }} - min={0} - /> - - - - - ) : ( -
- )} - + {phaseData.phaseEnabled ? ( + + errors={errors} + phaseData={phaseData} + phase={coldProperty} + isShowingErrors={isShowingErrors} + setPhaseData={setPhaseData} + rolloverEnabled={hotPhaseRolloverEnabled} + /> + ) : null} {phaseData.phaseEnabled ? ( + {/* Data tier allocation section */} + + + {/* Replicas section */} + + {i18n.translate('xpack.indexLifecycleMgmt.coldPhase.replicasTitle', { + defaultMessage: 'Replicas', + })} + + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel', + { defaultMessage: 'Set replicas' } + ), + initialValue: Boolean(phaseData.selectedReplicaCount), + onChange: (v) => { + if (!v) { + setPhaseData('selectedReplicaCount', ''); + } + }, + }} + fullWidth + > + + + + + } + isShowingErrors={isShowingErrors} + errors={errors?.selectedReplicaCount} + > + { + setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); + }} + min={0} + /> + + + {/* Freeze section */} @@ -191,8 +207,8 @@ export class ColdPhase extends PureComponent { onChange={(e) => { setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); }} - label={freezeLabel} - aria-label={freezeLabel} + label={i18nTexts.freezeLabel} + aria-label={i18nTexts.freezeLabel} /> @@ -204,7 +220,7 @@ export class ColdPhase extends PureComponent { /> ) : null} -
- ); - } -} + +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx index bfaf14143816..6a849cc2c3f1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx @@ -4,19 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { FunctionComponent, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFieldNumber, - EuiDescribedFormGroup, - EuiSwitch, - EuiTextColor, -} from '@elastic/eui'; +import { EuiFieldNumber, EuiDescribedFormGroup, EuiSwitch, EuiTextColor } from '@elastic/eui'; import { FrozenPhase as FrozenPhaseInterface, Phases } from '../../../../../common/types'; import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; @@ -28,13 +20,22 @@ import { OptionalLabel, ErrableFormRow, MinAgeInput, - NodeAllocation, SetPriorityInput, + DescribedFormField, } from '../components'; +import { DataTierAllocationField } from './shared'; -const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.freezeIndexLabel', { - defaultMessage: 'Freeze index', -}); +const i18nTexts = { + freezeLabel: i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.freezeIndexLabel', { + defaultMessage: 'Freeze index', + }), + dataTierAllocation: { + description: i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.dataTier.description', { + defaultMessage: + 'Move data to data nodes optimized for infrequent, read-only access. Store frozen data on the least-expensive hardware.', + }), + }, +}; const frozenProperty: keyof Phases = 'frozen'; const phaseProperty = (propertyName: keyof FrozenPhaseInterface) => propertyName; @@ -46,18 +47,17 @@ interface Props { errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } -export class FrozenPhase extends PureComponent { - render() { - const { - setPhaseData, - phaseData, - errors, - isShowingErrors, - hotPhaseRolloverEnabled, - } = this.props; - - return ( -
+export const FrozenPhase: FunctionComponent = ({ + setPhaseData, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, +}) => { + return ( +
+ <> + {/* Section title group; containing min age */} @@ -101,68 +101,82 @@ export class FrozenPhase extends PureComponent { } fullWidth > - - {phaseData.phaseEnabled ? ( - - - errors={errors} - phaseData={phaseData} - phase={frozenProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - rolloverEnabled={hotPhaseRolloverEnabled} - /> - - - - phase={frozenProperty} - setPhaseData={setPhaseData} - errors={errors} - phaseData={phaseData} - isShowingErrors={isShowingErrors} - /> - - - - - - - - } - isShowingErrors={isShowingErrors} - errors={errors?.freezeEnabled} - helpText={i18n.translate( - 'xpack.indexLifecycleMgmt.frozenPhase.replicaCountHelpText', - { - defaultMessage: 'By default, the number of replicas remains the same.', - } - )} - > - { - setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); - }} - min={0} - /> - - - - - ) : ( -
- )} - + {phaseData.phaseEnabled ? ( + + errors={errors} + phaseData={phaseData} + phase={frozenProperty} + isShowingErrors={isShowingErrors} + setPhaseData={setPhaseData} + rolloverEnabled={hotPhaseRolloverEnabled} + /> + ) : null} {phaseData.phaseEnabled ? ( + {/* Data tier allocation section */} + + + {/* Replicas section */} + + {i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.replicasTitle', { + defaultMessage: 'Replicas', + })} + + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.frozenPhase.numberOfReplicasDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.frozenPhase.numberOfReplicas.switchLabel', + { defaultMessage: 'Set replicas' } + ), + initialValue: Boolean(phaseData.selectedReplicaCount), + onChange: (v) => { + if (!v) { + setPhaseData('selectedReplicaCount', ''); + } + }, + }} + fullWidth + > + + + + + } + isShowingErrors={isShowingErrors} + errors={errors?.selectedReplicaCount} + > + { + setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); + }} + min={0} + /> + + @@ -191,8 +205,8 @@ export class FrozenPhase extends PureComponent { onChange={(e) => { setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); }} - label={freezeLabel} - aria-label={freezeLabel} + label={i18nTexts.freezeLabel} + aria-label={i18nTexts.freezeLabel} /> @@ -204,7 +218,7 @@ export class FrozenPhase extends PureComponent { /> ) : null} -
- ); - } -} + +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/data_tier_allocation_field.tsx new file mode 100644 index 000000000000..6475e5286a77 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/data_tier_allocation_field.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +import { PhaseWithAllocationAction, PhaseWithAllocation } from '../../../../../../common/types'; + +import { + DataTierAllocation, + DefaultAllocationWarning, + NoNodeAttributesWarning, + NodesDataProvider, +} from '../../components/data_tier_allocation'; +import { PhaseValidationErrors } from '../../../../services/policies/policy_validation'; +import { isPhaseDefaultDataAllocationCompatible } from '../../../../lib/data_tiers'; + +const i18nTexts = { + title: i18n.translate('xpack.indexLifecycleMgmt.common.dataTier.title', { + defaultMessage: 'Data allocation', + }), +}; + +interface Props { + description: React.ReactNode; + phase: PhaseWithAllocation; + setPhaseData: (dataKey: keyof PhaseWithAllocationAction, value: string) => void; + isShowingErrors: boolean; + errors?: PhaseValidationErrors; + phaseData: PhaseWithAllocationAction; +} + +/** + * Top-level layout control for the data tier allocation field. + */ +export const DataTierAllocationField: FunctionComponent = ({ + description, + phase, + phaseData, + setPhaseData, + isShowingErrors, + errors, +}) => { + return ( + + {(nodesData) => { + const isCompatible = isPhaseDefaultDataAllocationCompatible(phase, nodesData.nodesByRoles); + const hasNodeAttrs = Boolean(Object.keys(nodesData.nodesByAttributes ?? {}).length); + + return ( + {i18nTexts.title}} + description={description} + fullWidth + > + + <> + + + {/* Data tier related warnings */} + + {phaseData.dataTierAllocationType === 'default' && !isCompatible && ( + + )} + + {phaseData.dataTierAllocationType === 'custom' && !hasNodeAttrs && ( + + )} + + + + ); + }} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/index.ts new file mode 100644 index 000000000000..f9e939058adb --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/index.ts @@ -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 { DataTierAllocationField } from './data_tier_allocation_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx index c806056899ca..16a740b1171c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, PureComponent } from 'react'; +import React, { Fragment, FunctionComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { @@ -27,44 +27,53 @@ import { OptionalLabel, ErrableFormRow, SetPriorityInput, - NodeAllocation, MinAgeInput, + DescribedFormField, Forcemerge, } from '../components'; +import { DataTierAllocationField } from './shared'; -const shrinkLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { - defaultMessage: 'Shrink index', -}); - -const moveToWarmPhaseOnRolloverLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', - { - defaultMessage: 'Move to warm phase on rollover', - } -); +const i18nTexts = { + shrinkLabel: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { + defaultMessage: 'Shrink index', + }), + moveToWarmPhaseOnRolloverLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', + { + defaultMessage: 'Move to warm phase on rollover', + } + ), + dataTierAllocation: { + description: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.dataTier.description', { + defaultMessage: + 'Move warm data to nodes optimized for read-only access. Store warm data on less-expensive hardware.', + }), + }, +}; const warmProperty: keyof Phases = 'warm'; const phaseProperty = (propertyName: keyof WarmPhaseInterface) => propertyName; interface Props { - setPhaseData: (key: keyof WarmPhaseInterface & string, value: boolean | string) => void; + setPhaseData: ( + key: keyof WarmPhaseInterface & string, + value: boolean | string | undefined + ) => void; phaseData: WarmPhaseInterface; isShowingErrors: boolean; errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } -export class WarmPhase extends PureComponent { - render() { - const { - setPhaseData, - phaseData, - errors, - isShowingErrors, - hotPhaseRolloverEnabled, - } = this.props; - - return ( -
+export const WarmPhase: FunctionComponent = ({ + setPhaseData, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, +}) => { + return ( +
+ <> @@ -115,7 +124,7 @@ export class WarmPhase extends PureComponent { { @@ -137,58 +146,75 @@ export class WarmPhase extends PureComponent { /> ) : null} - - - - - phase={warmProperty} - setPhaseData={setPhaseData} - errors={errors} - phaseData={phaseData} - isShowingErrors={isShowingErrors} - /> - - - - - - - - } - isShowingErrors={isShowingErrors} - errors={errors?.selectedReplicaCount} - helpText={i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.replicaCountHelpText', - { - defaultMessage: 'By default, the number of replicas remains the same.', - } - )} - > - { - setPhaseData('selectedReplicaCount', e.target.value); - }} - min={0} - /> - - - - - ) : null} + {phaseData.phaseEnabled ? ( + {/* Data tier allocation section */} + + + + {i18n.translate('xpack.indexLifecycleMgmt.warmPhase.replicasTitle', { + defaultMessage: 'Replicas', + })} + + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.warmPhase.numberOfReplicas.switchLabel', + { defaultMessage: 'Set replicas' } + ), + initialValue: Boolean(phaseData.selectedReplicaCount), + onChange: (v) => { + if (!v) { + setPhaseData('selectedReplicaCount', ''); + } + }, + }} + fullWidth + > + + + + + } + isShowingErrors={isShowingErrors} + errors={errors?.selectedReplicaCount} + > + { + setPhaseData('selectedReplicaCount', e.target.value); + }} + min={0} + /> + + @@ -217,8 +243,8 @@ export class WarmPhase extends PureComponent { onChange={(e) => { setPhaseData(phaseProperty('shrinkEnabled'), e.target.checked); }} - label={shrinkLabel} - aria-label={shrinkLabel} + label={i18nTexts.shrinkLabel} + aria-label={i18nTexts.shrinkLabel} aria-controls="shrinkContent" /> @@ -275,7 +301,7 @@ export class WarmPhase extends PureComponent { /> ) : null} -
- ); - } -} + +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts index 3d068433becb..b279a5647c3e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts @@ -6,7 +6,7 @@ import { METRIC_TYPE } from '@kbn/analytics'; -import { PolicyFromES, SerializedPolicy } from '../../../common/types'; +import { PolicyFromES, SerializedPolicy, ListNodesRouteResponse } from '../../../common/types'; import { UIM_POLICY_DELETE, @@ -23,10 +23,10 @@ interface GenericObject { } export const useLoadNodes = () => { - return useRequest({ + return useRequest({ path: `nodes/list`, method: 'get', - initialData: [], + initialData: { nodesByAttributes: {}, nodesByRoles: {} } as ListNodesRouteResponse, }); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts index 3b71c1134975..70f172de390e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts @@ -13,6 +13,8 @@ import { PhaseValidationErrors, positiveNumberRequiredMessage, } from './policy_validation'; +import { determineDataTierAllocationType } from '../../lib'; +import { serializePhaseWithAllocation } from './shared'; const coldPhaseInitialization: ColdPhase = { phaseEnabled: false, @@ -22,6 +24,7 @@ const coldPhaseInitialization: ColdPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '', + dataTierAllocationType: 'default', }; export const coldPhaseFromES = (phaseSerialized?: SerializedColdPhase): ColdPhase => { @@ -32,6 +35,12 @@ export const coldPhaseFromES = (phaseSerialized?: SerializedColdPhase): ColdPhas phase.phaseEnabled = true; + if (phaseSerialized.actions.allocate) { + phase.dataTierAllocationType = determineDataTierAllocationType( + phaseSerialized.actions.allocate + ); + } + if (phaseSerialized.min_age) { const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); phase.selectedMinimumAge = minAge; @@ -80,19 +89,7 @@ export const coldPhaseToES = ( esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; } - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.selectedNodeAttrs) { - const [name, value] = phase.selectedNodeAttrs.split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } + esPhase.actions = serializePhaseWithAllocation(phase, esPhase.actions); if (isNumber(phase.selectedReplicaCount)) { esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts index 6249507bcb40..28d18b8f8926 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts @@ -13,6 +13,8 @@ import { PhaseValidationErrors, positiveNumberRequiredMessage, } from './policy_validation'; +import { determineDataTierAllocationType } from '../../lib'; +import { serializePhaseWithAllocation } from './shared'; const frozenPhaseInitialization: FrozenPhase = { phaseEnabled: false, @@ -22,6 +24,7 @@ const frozenPhaseInitialization: FrozenPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '', + dataTierAllocationType: 'default', }; export const frozenPhaseFromES = (phaseSerialized?: SerializedFrozenPhase): FrozenPhase => { @@ -32,6 +35,12 @@ export const frozenPhaseFromES = (phaseSerialized?: SerializedFrozenPhase): Froz phase.phaseEnabled = true; + if (phaseSerialized.actions.allocate) { + phase.dataTierAllocationType = determineDataTierAllocationType( + phaseSerialized.actions.allocate + ); + } + if (phaseSerialized.min_age) { const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); phase.selectedMinimumAge = minAge; @@ -80,19 +89,7 @@ export const frozenPhaseToES = ( esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; } - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.selectedNodeAttrs) { - const [name, value] = phase.selectedNodeAttrs.split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } + esPhase.actions = serializePhaseWithAllocation(phase, esPhase.actions); if (isNumber(phase.selectedReplicaCount)) { esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts new file mode 100644 index 000000000000..0e7257d437ee --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts @@ -0,0 +1,464 @@ +/* + * 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 cloneDeep from 'lodash/cloneDeep'; +import { serializePolicy } from './policy_serialization'; +import { + defaultNewColdPhase, + defaultNewDeletePhase, + defaultNewFrozenPhase, + defaultNewHotPhase, + defaultNewWarmPhase, +} from '../../constants'; +import { DataTierAllocationType } from '../../../../common/types'; + +describe('Policy serialization', () => { + test('serialize a policy using "default" data allocation', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'default', + // These selected attrs should be ignored + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'default', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'default', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + frozen: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + } + ) + ).toEqual({ + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + frozen: { + actions: { + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialize a policy using "custom" data allocation', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { something: 'here' }, + }, + }, + }, + cold: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { something: 'here' }, + }, + }, + }, + frozen: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { something: 'here' }, + }, + }, + }, + }, + } + ) + ).toEqual({ + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { + another: 'thing', + }, + }, + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { + another: 'thing', + }, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + frozen: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { + another: 'thing', + }, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialize a policy using "custom" data allocation with no node attributes', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: '', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: '', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: '', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + frozen: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + } + ) + ).toEqual({ + // There should be no allocation action in any phases... + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + allocate: { include: {}, exclude: {}, require: { something: 'here' } }, + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + allocate: { include: {}, exclude: {}, require: { something: 'here' } }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + frozen: { + actions: { + allocate: { include: {}, exclude: {}, require: { something: 'here' } }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialize a policy using "none" data allocation with no node attributes', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'none', + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'none', + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'none', + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + frozen: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + } + ) + ).toEqual({ + // There should be no allocation action in any phases... + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + migrate: { + enabled: false, + }, + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + migrate: { + enabled: false, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + frozen: { + actions: { + migrate: { + enabled: false, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialization does not alter the original policy', () => { + const originalPolicy = { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + frozen: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + }; + + const originalClone = cloneDeep(originalPolicy); + + const deserializedPolicy = { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'none' as DataTierAllocationType, + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'none' as DataTierAllocationType, + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'none' as DataTierAllocationType, + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }; + + serializePolicy(deserializedPolicy, originalPolicy); + deserializedPolicy.phases.warm.dataTierAllocationType = 'custom'; + serializePolicy(deserializedPolicy, originalPolicy); + deserializedPolicy.phases.warm.dataTierAllocationType = 'default'; + serializePolicy(deserializedPolicy, originalPolicy); + expect(originalPolicy).toEqual(originalClone); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/index.ts new file mode 100644 index 000000000000..fe97b85778a5 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/index.ts @@ -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 { serializePhaseWithAllocation } from './serialize_phase_with_allocation'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/serialize_phase_with_allocation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/serialize_phase_with_allocation.ts new file mode 100644 index 000000000000..5a9db3069aea --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/serialize_phase_with_allocation.ts @@ -0,0 +1,40 @@ +/* + * 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 cloneDeep from 'lodash/cloneDeep'; + +import { + AllocateAction, + PhaseWithAllocationAction, + SerializedPhase, +} from '../../../../../common/types'; + +export const serializePhaseWithAllocation = ( + phase: PhaseWithAllocationAction, + originalPhaseActions: SerializedPhase['actions'] = {} +): SerializedPhase['actions'] => { + const esPhaseActions: SerializedPhase['actions'] = cloneDeep(originalPhaseActions); + + if (phase.dataTierAllocationType === 'custom' && phase.selectedNodeAttrs) { + const [name, value] = phase.selectedNodeAttrs.split(':'); + esPhaseActions.allocate = esPhaseActions.allocate || ({} as AllocateAction); + esPhaseActions.allocate.require = { + [name]: value, + }; + } else if (phase.dataTierAllocationType === 'none') { + esPhaseActions.migrate = { enabled: false }; + if (esPhaseActions.allocate) { + delete esPhaseActions.allocate; + } + } else if (phase.dataTierAllocationType === 'default') { + if (esPhaseActions.allocate) { + delete esPhaseActions.allocate.require; + } + delete esPhaseActions.migrate; + } + + return esPhaseActions; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts index cc815d67dbc1..6971f652f986 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts @@ -16,6 +16,9 @@ import { positiveNumbersAboveZeroErrorMessage, } from './policy_validation'; +import { determineDataTierAllocationType } from '../../lib'; +import { serializePhaseWithAllocation } from './shared'; + const warmPhaseInitialization: WarmPhase = { phaseEnabled: false, warmPhaseOnRollover: false, @@ -28,6 +31,7 @@ const warmPhaseInitialization: WarmPhase = { forceMergeEnabled: false, selectedForceMergeSegments: '', phaseIndexPriority: '', + dataTierAllocationType: 'default', }; export const warmPhaseFromES = (phaseSerialized?: SerializedWarmPhase): WarmPhase => { @@ -39,6 +43,12 @@ export const warmPhaseFromES = (phaseSerialized?: SerializedWarmPhase): WarmPhas phase.phaseEnabled = true; + if (phaseSerialized.actions.allocate) { + phase.dataTierAllocationType = determineDataTierAllocationType( + phaseSerialized.actions.allocate + ); + } + if (phaseSerialized.min_age) { if (phaseSerialized.min_age === '0ms') { phase.warmPhaseOnRollover = true; @@ -99,19 +109,7 @@ export const warmPhaseToES = ( delete esPhase.min_age; } - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.selectedNodeAttrs) { - const [name, value] = phase.selectedNodeAttrs.split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } + esPhase.actions = serializePhaseWithAllocation(phase, esPhase.actions); if (isNumber(phase.selectedReplicaCount)) { esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts index b30a59c997e8..99df70e7df82 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts @@ -6,22 +6,45 @@ import { LegacyAPICaller } from 'src/core/server'; +import { ListNodesRouteResponse, NodeDataRole } from '../../../../common/types'; + import { RouteDependencies } from '../../../types'; import { addBasePath } from '../../../services'; -function convertStatsIntoList(stats: any, disallowedNodeAttributes: string[]): any { - return Object.entries(stats.nodes).reduce((accum: any, [nodeId, nodeStats]: [any, any]) => { - const attributes = nodeStats.attributes || {}; - for (const [key, value] of Object.entries(attributes)) { - const isNodeAttributeAllowed = !disallowedNodeAttributes.includes(key); - if (isNodeAttributeAllowed) { - const attributeString = `${key}:${value}`; - accum[attributeString] = accum[attributeString] || []; - accum[attributeString].push(nodeId); +interface Stats { + nodes: { + [nodeId: string]: { + attributes: Record; + roles: string[]; + }; + }; +} + +function convertStatsIntoList( + stats: Stats, + disallowedNodeAttributes: string[] +): ListNodesRouteResponse { + return Object.entries(stats.nodes).reduce( + (accum, [nodeId, nodeStats]) => { + const attributes = nodeStats.attributes || {}; + for (const [key, value] of Object.entries(attributes)) { + const isNodeAttributeAllowed = !disallowedNodeAttributes.includes(key); + if (isNodeAttributeAllowed) { + const attributeString = `${key}:${value}`; + accum.nodesByAttributes[attributeString] = accum.nodesByAttributes[attributeString] ?? []; + accum.nodesByAttributes[attributeString].push(nodeId); + } } - } - return accum; - }, {}); + + const dataRoles = nodeStats.roles.filter((r) => r.startsWith('data')) as NodeDataRole[]; + for (const role of dataRoles) { + accum.nodesByRoles[role as NodeDataRole] = accum.nodesByRoles[role] ?? []; + accum.nodesByRoles[role as NodeDataRole]!.push(nodeId); + } + return accum; + }, + { nodesByAttributes: {}, nodesByRoles: {} } as ListNodesRouteResponse + ); } async function fetchNodeStats(callAsCurrentUser: LegacyAPICaller): Promise { @@ -54,8 +77,8 @@ export function registerListRoute({ router, config, license, lib }: RouteDepende const stats = await fetchNodeStats( context.core.elasticsearch.legacy.client.callAsCurrentUser ); - const okResponse = { body: convertStatsIntoList(stats, disallowedNodeAttributes) }; - return response.ok(okResponse); + const body: ListNodesRouteResponse = convertStatsIntoList(stats, disallowedNodeAttributes); + return response.ok({ body }); } catch (e) { if (lib.isEsError(e)) { return response.customError({ diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts index ba6b8665479a..5ef38a0e46dc 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -40,6 +40,8 @@ const setPrioritySchema = schema.maybe( const unfollowSchema = schema.maybe(schema.object({})); // Unfollow has no options +const migrateSchema = schema.maybe(schema.object({ enabled: schema.literal(false) })); + const allocateNodeSchema = schema.maybe(schema.recordOf(schema.string(), schema.string())); const allocateSchema = schema.maybe( schema.object({ @@ -76,6 +78,7 @@ const warmPhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, actions: schema.object({ + migrate: migrateSchema, set_priority: setPrioritySchema, unfollow: unfollowSchema, readonly: schema.maybe(schema.object({})), // Readonly has no options @@ -94,6 +97,7 @@ const coldPhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, actions: schema.object({ + migrate: migrateSchema, set_priority: setPrioritySchema, unfollow: unfollowSchema, allocate: allocateSchema, @@ -111,6 +115,7 @@ const frozenPhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, actions: schema.object({ + migrate: migrateSchema, set_priority: setPrioritySchema, unfollow: unfollowSchema, allocate: allocateSchema, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 916dab88d8b7..76a3b58a8f3e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7978,7 +7978,6 @@ "xpack.indexLifecycleMgmt.appTitle": "インデックスライフサイクルポリシー", "xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel": "インデックスを凍結", "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasLabel": "複製の数", - "xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText": "デフォルトで、複製の数は同じままになります。", "xpack.indexLifecycleMgmt.confirmDelete.cancelButton": "キャンセル", "xpack.indexLifecycleMgmt.confirmDelete.deleteButton": "削除", "xpack.indexLifecycleMgmt.confirmDelete.errorMessage": "ポリシー {policyName} の削除中にエラーが発生しました", @@ -7986,7 +7985,6 @@ "xpack.indexLifecycleMgmt.confirmDelete.title": "ポリシー「{name}」が削除されました", "xpack.indexLifecycleMgmt.confirmDelete.undoneWarning": "削除されたポリシーは復元できません。", "xpack.indexLifecycleMgmt.editPolicy.cancelButton": "キャンセル", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.activateWarmPhaseSwitchLabel": "コールドフェーズを有効にする", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescriptionText": "インデックスへのクエリの頻度を減らすことで、大幅に性能が低いハードウェアにシャードを割り当てることができます。クエリが遅いため、複製の数を減らすことができます。", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseLabel": "コールドフェーズ", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeIndexExplanationText": "凍結されたインデックスはクラスターにほとんどオーバーヘッドがなく、書き込みオペレーションがブロックされます。凍結されたインデックスは検索できますが、クエリが遅くなります。", @@ -8032,7 +8030,6 @@ "xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError": "最大インデックスサイズが必要です。", "xpack.indexLifecycleMgmt.editPolicy.nameLabel": "名前", "xpack.indexLifecycleMgmt.editPolicy.nodeAllocationLabel": "シャードの割当をコントロールするノード属性を選択", - "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingDescription": "ノード属性なしではシャードの割り当てをコントロールできません。", "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingLabel": "elasticsearch.yml でノード属性が構成されていません", "xpack.indexLifecycleMgmt.editPolicy.numberRequiredError": "数字が必要です。", "xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel": "コールドフェーズのタイミング", @@ -8188,7 +8185,6 @@ "xpack.indexLifecycleMgmt.warmPhase.numberOfPrimaryShardsLabel": "プライマリシャードの数", "xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasLabel": "レプリカの数", "xpack.indexLifecycleMgmt.warmPhase.numberOfSegmentsLabel": "セグメントの数", - "xpack.indexLifecycleMgmt.warmPhase.replicaCountHelpText": "デフォルトで、レプリカの数は同じままになります。", "xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel": "インデックスを縮小", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "なし(グループなし)", "xpack.infra.alerting.alertFlyout.groupByLabel": "グループ分けの条件", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ffaf281487fd..89c7a03e099d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7982,7 +7982,6 @@ "xpack.indexLifecycleMgmt.appTitle": "索引生命周期策略", "xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel": "冻结索引", "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasLabel": "副本分片数目", - "xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText": "默认情况下,副本分片数目仍一样。", "xpack.indexLifecycleMgmt.confirmDelete.cancelButton": "取消", "xpack.indexLifecycleMgmt.confirmDelete.deleteButton": "删除", "xpack.indexLifecycleMgmt.confirmDelete.errorMessage": "删除策略 {policyName} 时出错", @@ -7990,7 +7989,6 @@ "xpack.indexLifecycleMgmt.confirmDelete.title": "删除策略“{name}”", "xpack.indexLifecycleMgmt.confirmDelete.undoneWarning": "无法恢复删除的策略。", "xpack.indexLifecycleMgmt.editPolicy.cancelButton": "取消", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.activateWarmPhaseSwitchLabel": "激活冷阶段", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescriptionText": "您查询自己索引的频率较低,因此您可以在效率较低的硬件上分配分片。因为您的查询较为缓慢,所以您可以减少副本分片数目。", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseLabel": "冷阶段", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeIndexExplanationText": "冻结的索引在集群上有很少的开销,已被阻止进行写操作。您可以搜索冻结的索引,但查询应会较慢。", @@ -8036,7 +8034,6 @@ "xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError": "最大索引大小必填。", "xpack.indexLifecycleMgmt.editPolicy.nameLabel": "名称", "xpack.indexLifecycleMgmt.editPolicy.nodeAllocationLabel": "选择节点属性来控制分片分配", - "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingDescription": "没有节点属性,将无法控制分片分配。", "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingLabel": "elasticsearch.yml 中未配置任何节点属性", "xpack.indexLifecycleMgmt.editPolicy.numberRequiredError": "数字必填。", "xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel": "冷阶段计时", @@ -8192,7 +8189,6 @@ "xpack.indexLifecycleMgmt.warmPhase.numberOfPrimaryShardsLabel": "主分片数目", "xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasLabel": "副本分片数目", "xpack.indexLifecycleMgmt.warmPhase.numberOfSegmentsLabel": "段数目", - "xpack.indexLifecycleMgmt.warmPhase.replicaCountHelpText": "默认情况下,副本分片数目仍一样。", "xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel": "缩小索引", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "无内容(未分组)", "xpack.infra.alerting.alertFlyout.groupByLabel": "分组依据", diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/nodes.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/nodes.js index bc8b2af40142..bb35f6fd9642 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/nodes.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/nodes.js @@ -29,7 +29,7 @@ export default function ({ getService }) { const nodesIds = Object.keys(nodeStats.nodes); const { body } = await loadNodes().expect(200); - expect(body[NODE_CUSTOM_ATTRIBUTE]).to.eql(nodesIds); + expect(body.nodesByAttributes[NODE_CUSTOM_ATTRIBUTE]).to.eql(nodesIds); }); });