diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index ea463a75d474..1bab96e219d0 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -413,6 +413,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { secureCluster: `${ELASTICSEARCH_DOCS}secure-cluster.html`, shardAllocationSettings: `${ELASTICSEARCH_DOCS}modules-cluster.html#cluster-shard-allocation-settings`, sortSearch: `${ELASTICSEARCH_DOCS}sort-search-results.html`, + tutorialUpdateExistingDataStream: `${ELASTICSEARCH_DOCS}tutorial-manage-existing-data-stream.html`, transportSettings: `${ELASTICSEARCH_DOCS}modules-network.html#common-network-settings`, typesRemoval: `${ELASTICSEARCH_DOCS}removal-of-types.html`, setupUpgrade: `${ELASTICSEARCH_DOCS}setup-upgrade.html`, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index b935a19160e4..9f5b5611925d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -90,6 +90,18 @@ const registerHttpRequestMockHelpers = ( const setDeleteDataStreamResponse = (response?: HttpResponse, error?: ResponseError) => mockResponse('POST', `${API_BASE_PATH}/delete_data_streams`, response, error); + const setEditDataRetentionResponse = ( + dataStreamId: string, + response?: HttpResponse, + error?: ResponseError + ) => + mockResponse( + 'PUT', + `${API_BASE_PATH}/data_streams/${encodeURIComponent(dataStreamId)}/data_retention`, + response, + error + ); + const setDeleteTemplateResponse = (response?: HttpResponse, error?: ResponseError) => mockResponse('POST', `${API_BASE_PATH}/delete_index_templates`, response, error); @@ -196,6 +208,7 @@ const registerHttpRequestMockHelpers = ( setLoadDataStreamResponse, setDeleteDataStreamResponse, setDeleteTemplateResponse, + setEditDataRetentionResponse, setLoadTemplateResponse, setCreateTemplateResponse, setLoadIndexSettingsResponse, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index e8511edb44ea..b6656c0dae35 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -92,5 +92,14 @@ export type TestSubjects = | 'createAndExecuteButton' | 'enrichPolicySummaryList' | 'requestBody' + | 'editDataRetentionButton' | 'errorWhenCreatingCallout' + | 'manageDataStreamButton' + | 'dataRetentionValue' + | 'policyNameField' + | 'configuredByILMWarning' + | 'show-filters-button' + | 'filter-option-h' + | 'infiniteRetentionPeriod.input' + | 'saveButton' | 'createIndexSaveButton'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index d9e6694d8ba8..6629502498c7 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -34,6 +34,7 @@ export interface DataStreamsTabTestBed extends TestBed { selectDataStream: (name: string, selected: boolean) => void; clickConfirmDelete: () => void; clickDeleteDataStreamButton: () => void; + clickEditDataRetentionButton: () => void; clickDetailPanelIndexTemplateLink: () => void; }; findDeleteActionAt: (index: number) => ReactWrapper; @@ -176,8 +177,13 @@ export const setup = async ( }; const clickDeleteDataStreamButton = () => { - const { find } = testBed; - find('deleteDataStreamButton').simulate('click'); + testBed.find('manageDataStreamButton').simulate('click'); + testBed.find('deleteDataStreamButton').simulate('click'); + }; + + const clickEditDataRetentionButton = () => { + testBed.find('manageDataStreamButton').simulate('click'); + testBed.find('editDataRetentionButton').simulate('click'); }; const clickDetailPanelIndexTemplateLink = async () => { @@ -236,6 +242,7 @@ export const setup = async ( selectDataStream, clickConfirmDelete, clickDeleteDataStreamButton, + clickEditDataRetentionButton, clickDetailPanelIndexTemplateLink, }, findDeleteActionAt, @@ -267,6 +274,7 @@ export const createDataStreamPayload = (dataStream: Partial): DataSt maxTimeStamp: 420, privileges: { delete_index: true, + manage_data_stream_lifecycle: true, }, hidden: false, lifecycle: { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index 9e35f288499c..c40823509d64 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -7,6 +7,7 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; +import { notificationServiceMock } from '@kbn/core/public/mocks'; import { breadcrumbService, @@ -15,6 +16,7 @@ import { import { API_BASE_PATH } from '../../../common/constants'; import * as fixtures from '../../../test/fixtures'; import { setupEnvironment } from '../helpers'; +import { notificationService } from '../../../public/application/services/notification'; import { DataStreamsTabTestBed, @@ -134,6 +136,8 @@ describe('Data Streams tab', () => { }); describe('when there are data streams', () => { + const notificationsServiceMock = notificationServiceMock.createStartContract(); + beforeEach(async () => { const { setLoadIndicesResponse, @@ -169,7 +173,13 @@ describe('Data Streams tab', () => { setLoadTemplatesResponse({ templates: [indexTemplate], legacyTemplates: [] }); setLoadTemplateResponse(indexTemplate.name, indexTemplate); - testBed = await setup(httpSetup, { history: createMemoryHistory() }); + notificationService.setup(notificationsServiceMock); + testBed = await setup(httpSetup, { + history: createMemoryHistory(), + services: { + notificationService, + }, + }); await act(async () => { testBed.actions.goToDataStreamsList(); }); @@ -327,6 +337,64 @@ describe('Data Streams tab', () => { ); }); + describe('update data retention', () => { + test('can set data retention period', async () => { + const { + actions: { clickNameAt, clickEditDataRetentionButton }, + } = testBed; + + await clickNameAt(0); + + clickEditDataRetentionButton(); + + httpRequestsMockHelpers.setEditDataRetentionResponse('dataStream1', { + success: true, + }); + + // set data retention value + testBed.form.setInputValue('dataRetentionValue', '7'); + // Set data retention unit + testBed.find('show-filters-button').simulate('click'); + testBed.find('filter-option-h').simulate('click'); + + await act(async () => { + testBed.find('saveButton').simulate('click'); + }); + testBed.component.update(); + + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/data_streams/dataStream1/data_retention`, + expect.objectContaining({ body: JSON.stringify({ dataRetention: '7h' }) }) + ); + }); + + test('allows to set infinite retention period', async () => { + const { + actions: { clickNameAt, clickEditDataRetentionButton }, + } = testBed; + + await clickNameAt(0); + + clickEditDataRetentionButton(); + + httpRequestsMockHelpers.setEditDataRetentionResponse('dataStream1', { + success: true, + }); + + testBed.form.toggleEuiSwitch('infiniteRetentionPeriod.input'); + + await act(async () => { + testBed.find('saveButton').simulate('click'); + }); + testBed.component.update(); + + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/data_streams/dataStream1/data_retention`, + expect.objectContaining({ body: JSON.stringify({}) }) + ); + }); + }); + test('clicking index template name navigates to the index template details', async () => { const { actions: { clickNameAt, clickDetailPanelIndexTemplateLink }, @@ -423,6 +491,33 @@ describe('Data Streams tab', () => { expect(findDetailPanelIlmPolicyLink().prop('href')).toBe('/test/my_ilm_policy'); }); + test('with ILM updating data retention should be disabled', async () => { + const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; + + const dataStreamForDetailPanel = createDataStreamPayload({ + name: 'dataStream1', + ilmPolicyName: 'my_ilm_policy', + }); + + setLoadDataStreamsResponse([dataStreamForDetailPanel]); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); + + testBed = await setup(httpSetup, { + history: createMemoryHistory(), + url: urlServiceMock, + }); + await act(async () => { + testBed.actions.goToDataStreamsList(); + }); + testBed.component.update(); + + const { actions } = testBed; + await actions.clickNameAt(0); + + testBed.find('manageDataStreamButton').simulate('click'); + expect(testBed.find('editDataRetentionButton').exists()).toBeFalsy(); + }); + test('with an ILM url locator and no ILM policy', async () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; @@ -567,15 +662,29 @@ describe('Data Streams tab', () => { const dataStreamWithDelete = createDataStreamPayload({ name: 'dataStreamWithDelete', - privileges: { delete_index: true }, + privileges: { delete_index: true, manage_data_stream_lifecycle: true }, }); const dataStreamNoDelete = createDataStreamPayload({ name: 'dataStreamNoDelete', - privileges: { delete_index: false }, + privileges: { delete_index: false, manage_data_stream_lifecycle: true }, + }); + const dataStreamNoEditRetention = createDataStreamPayload({ + name: 'dataStreamNoEditRetention', + privileges: { delete_index: true, manage_data_stream_lifecycle: false }, + }); + + const dataStreamNoPermissions = createDataStreamPayload({ + name: 'dataStreamNoPermissions', + privileges: { delete_index: false, manage_data_stream_lifecycle: false }, }); beforeEach(async () => { - setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]); + setLoadDataStreamsResponse([ + dataStreamWithDelete, + dataStreamNoDelete, + dataStreamNoEditRetention, + dataStreamNoPermissions, + ]); testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock }); await act(async () => { @@ -590,6 +699,8 @@ describe('Data Streams tab', () => { expect(tableCellsValues).toEqual([ ['', 'dataStreamNoDelete', 'green', '1', '7d', ''], + ['', 'dataStreamNoEditRetention', 'green', '1', '7d', 'Delete'], + ['', 'dataStreamNoPermissions', 'green', '1', '7d', ''], ['', 'dataStreamWithDelete', 'green', '1', '7d', 'Delete'], ]); }); @@ -610,17 +721,6 @@ describe('Data Streams tab', () => { expect(find('deleteDataStreamsButton').exists()).toBeTruthy(); }); - test('displays delete button in detail panel', async () => { - const { - actions: { clickNameAt }, - find, - } = testBed; - setLoadDataStreamResponse(dataStreamWithDelete.name, dataStreamWithDelete); - await clickNameAt(1); - - expect(find('deleteDataStreamButton').exists()).toBeTruthy(); - }); - test('hides delete button in detail panel', async () => { const { actions: { clickNameAt }, @@ -629,8 +729,44 @@ describe('Data Streams tab', () => { setLoadDataStreamResponse(dataStreamNoDelete.name, dataStreamNoDelete); await clickNameAt(0); + testBed.find('manageDataStreamButton').simulate('click'); expect(find('deleteDataStreamButton').exists()).toBeFalsy(); }); + + test('hides edit data retention button if no permissions', async () => { + const { + actions: { clickNameAt }, + find, + } = testBed; + setLoadDataStreamResponse(dataStreamNoEditRetention.name, dataStreamNoEditRetention); + await clickNameAt(1); + + testBed.find('manageDataStreamButton').simulate('click'); + expect(find('editDataRetentionButton').exists()).toBeFalsy(); + }); + + test('hides manage button if no permissions', async () => { + const { + actions: { clickNameAt }, + find, + } = testBed; + setLoadDataStreamResponse(dataStreamNoPermissions.name, dataStreamNoPermissions); + await clickNameAt(2); + + expect(find('manageDataStreamButton').exists()).toBeFalsy(); + }); + + test('displays delete button in detail panel', async () => { + const { + actions: { clickNameAt }, + find, + } = testBed; + setLoadDataStreamResponse(dataStreamWithDelete.name, dataStreamWithDelete); + await clickNameAt(3); + + testBed.find('manageDataStreamButton').simulate('click'); + expect(find('deleteDataStreamButton').exists()).toBeTruthy(); + }); }); }); }); diff --git a/x-pack/plugins/index_management/common/index.ts b/x-pack/plugins/index_management/common/index.ts index a481d17615d8..ea1316ed2c18 100644 --- a/x-pack/plugins/index_management/common/index.ts +++ b/x-pack/plugins/index_management/common/index.ts @@ -10,6 +10,6 @@ export { API_BASE_PATH, INTERNAL_API_BASE_PATH, BASE_PATH, MAJOR_VERSION } from './constants'; -export { getTemplateParameter } from './lib'; +export { getTemplateParameter, splitSizeAndUnits } from './lib'; export * from './types'; diff --git a/x-pack/plugins/index_management/common/lib/data_stream_serialization.test.ts b/x-pack/plugins/index_management/common/lib/data_stream_serialization.test.ts new file mode 100644 index 000000000000..334e6bbf97de --- /dev/null +++ b/x-pack/plugins/index_management/common/lib/data_stream_serialization.test.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { splitSizeAndUnits } from './data_stream_serialization'; + +describe('Data stream serialization', () => { + test('can split size and units from lifecycle string', () => { + expect(splitSizeAndUnits('1h')).toEqual({ size: '1', unit: 'h' }); + expect(splitSizeAndUnits('20micron')).toEqual({ size: '20', unit: 'micron' }); + }); +}); diff --git a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts index 78593b40b5ea..b9743727a175 100644 --- a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts @@ -54,3 +54,19 @@ export function deserializeDataStreamList( ): DataStream[] { return dataStreamsFromEs.map((dataStream) => deserializeDataStream(dataStream)); } + +export const splitSizeAndUnits = (field: string): { size: string; unit: string } => { + let size = ''; + let unit = ''; + + const result = /(\d+)(\w+)/.exec(field); + if (result) { + size = result[1]; + unit = result[2]; + } + + return { + size, + unit, + }; +}; diff --git a/x-pack/plugins/index_management/common/lib/index.ts b/x-pack/plugins/index_management/common/lib/index.ts index 095742e3c367..d46d3d8b6a1d 100644 --- a/x-pack/plugins/index_management/common/lib/index.ts +++ b/x-pack/plugins/index_management/common/lib/index.ts @@ -5,7 +5,11 @@ * 2.0. */ -export { deserializeDataStream, deserializeDataStreamList } from './data_stream_serialization'; +export { + deserializeDataStream, + deserializeDataStreamList, + splitSizeAndUnits, +} from './data_stream_serialization'; export { deserializeTemplate, diff --git a/x-pack/plugins/index_management/common/types/data_streams.ts b/x-pack/plugins/index_management/common/types/data_streams.ts index 88e4bc237e81..8e2e5c3368ac 100644 --- a/x-pack/plugins/index_management/common/types/data_streams.ts +++ b/x-pack/plugins/index_management/common/types/data_streams.ts @@ -21,6 +21,7 @@ type TimestampField = TimestampFieldFromEs; interface PrivilegesFromEs { delete_index: boolean; + manage_data_stream_lifecycle: boolean; } type Privileges = PrivilegesFromEs; @@ -33,6 +34,7 @@ export interface EnhancedDataStreamFromEs extends IndicesDataStream { maximum_timestamp?: IndicesDataStreamsStatsDataStreamsStatsItem['maximum_timestamp']; privileges: { delete_index: boolean; + manage_data_stream_lifecycle: boolean; }; } diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index 28fc4a5f1521..d04976d15740 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -7,6 +7,7 @@ import React, { useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiButtonEmpty, @@ -22,6 +23,10 @@ import { EuiIconTip, EuiLink, EuiTitle, + EuiIcon, + EuiPopover, + EuiContextMenu, + EuiContextMenuPanelDescriptor, } from '@elastic/eui'; import { DiscoverLink } from '../../../../lib/discover_link'; @@ -29,6 +34,7 @@ import { SectionLoading, reactRouterNavigate } from '../../../../../shared_impor import { SectionError, Error, DataHealth } from '../../../../components'; import { useLoadDataStream } from '../../../../services/api'; import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal'; +import { EditDataRetentionModal } from '../edit_data_retention_modal'; import { humanizeTimeStamp } from '../humanize_time_stamp'; import { getIndexListUri, getTemplateDetailsLink } from '../../../../services/routing'; import { ILM_PAGES_POLICY_EDIT } from '../../../../constants'; @@ -70,7 +76,10 @@ const DetailsList: React.FunctionComponent = ({ details }) => const midpoint = Math.ceil(descriptionListItems.length / 2); const descriptionListColumnOne = descriptionListItems.slice(0, midpoint); - const descriptionListColumnTwo = descriptionListItems.slice(-midpoint); + const descriptionListColumnTwo = descriptionListItems.slice( + midpoint, + descriptionListItems.length + ); return ( @@ -93,9 +102,11 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ dataStreamName, onClose, }) => { - const { error, data: dataStream, isLoading } = useLoadDataStream(dataStreamName); - + const [isManagePopOverOpen, setManagePopOver] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [isEditingDataRetention, setIsEditingDataRetention] = useState(false); + + const { error, data: dataStream, isLoading } = useLoadDataStream(dataStreamName); const ilmPolicyLink = useIlmLocator(ILM_PAGES_POLICY_EDIT, dataStream?.ilmPolicyName); const { history } = useAppContext(); @@ -275,9 +286,74 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ content = ; } + const closePopover = () => { + setManagePopOver(false); + }; + + const button = ( + setManagePopOver(!isManagePopOverOpen)} + > + + + ); + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + title: i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.managePanelTitle', { + defaultMessage: 'Data stream options', + }), + items: [ + ...(!dataStream?.ilmPolicyName && dataStream?.privileges?.manage_data_stream_lifecycle + ? [ + { + key: 'editDataRetention', + name: i18n.translate( + 'xpack.idxMgmt.dataStreamDetailPanel.managePanelEditDataRetention', + { + defaultMessage: 'Edit data retention', + } + ), + 'data-test-subj': 'editDataRetentionButton', + icon: , + onClick: () => { + closePopover(); + setIsEditingDataRetention(true); + }, + }, + ] + : []), + ...(dataStream?.privileges?.delete_index + ? [ + { + key: 'deleteDataStream', + name: i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.managePanelDelete', { + defaultMessage: 'Delete', + }), + 'data-test-subj': 'deleteDataStreamButton', + icon: , + onClick: () => { + closePopover(); + setIsDeleting(true); + }, + }, + ] + : []), + ], + }, + ]; + return ( <> - {isDeleting ? ( + {isDeleting && ( { if (data && data.hasDeletedDataStreams) { @@ -288,7 +364,21 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ }} dataStreams={[dataStreamName]} /> - ) : null} + )} + + {isEditingDataRetention && ( + { + if (data && data?.hasUpdatedDataRetention) { + onClose(true); + } else { + setIsEditingDataRetention(false); + } + }} + dataStreamName={dataStreamName} + dataRetention={dataStream?.lifecycle?.data_retention as string} + /> + )} onClose()} @@ -324,20 +414,19 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ - {!isLoading && !error && dataStream?.privileges.delete_index ? ( + {!isLoading && !error && panels[0].items?.length && ( - setIsDeleting(true)} - data-test-subj="deleteDataStreamButton" + - {i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.deleteButtonLabel', { - defaultMessage: 'Delete data stream', - })} - + + - ) : null} + )} diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/edit_data_retention_modal.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/edit_data_retention_modal.tsx new file mode 100644 index 000000000000..edd4ab7d6ff7 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/edit_data_retention_modal.tsx @@ -0,0 +1,315 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiButtonEmpty, + EuiButton, + EuiSpacer, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + useForm, + useFormData, + useFormIsModified, + Form, + fieldFormatters, + FormSchema, + FIELD_TYPES, + UseField, + ToggleField, + NumericField, +} from '../../../../../shared_imports'; + +import { documentationService } from '../../../../services/documentation'; +import { splitSizeAndUnits } from '../../../../../../common'; +import { useAppContext } from '../../../../app_context'; +import { UnitField } from './unit_field'; +import { updateDataRetention } from '../../../../services/api'; + +interface Props { + dataRetention: string; + dataStreamName: string; + onClose: (data?: { hasUpdatedDataRetention: boolean }) => void; +} + +export const timeUnits = [ + { + value: 'd', + text: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnits.daysLabel', + { + defaultMessage: 'days', + } + ), + }, + { + value: 'h', + text: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnits.hoursLabel', + { + defaultMessage: 'hours', + } + ), + }, + { + value: 'm', + text: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnits.minutesLabel', + { + defaultMessage: 'minutes', + } + ), + }, + { + value: 's', + text: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnits.secondsLabel', + { + defaultMessage: 'seconds', + } + ), + }, + { + value: 'ms', + text: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnits.millisecondsLabel', + { + defaultMessage: 'milliseconds', + } + ), + }, + { + value: 'micros', + text: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnits.microsecondsLabel', + { + defaultMessage: 'microseconds', + } + ), + }, + { + value: 'nanos', + text: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnits.nanosecondsLabel', + { + defaultMessage: 'nanoseconds', + } + ), + }, +]; + +const configurationFormSchema: FormSchema = { + dataRetention: { + type: FIELD_TYPES.TEXT, + label: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionField', + { + defaultMessage: 'Data retention', + } + ), + formatters: [fieldFormatters.toInt], + validations: [ + { + validator: ({ value }) => { + if (!value) { + return { + message: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldRequiredError', + { + defaultMessage: 'A data retention value is required.', + } + ), + }; + } + if (value <= 0) { + return { + message: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldNonNegativeError', + { + defaultMessage: `A positive value is required.`, + } + ), + }; + } + }, + }, + ], + }, + timeUnit: { + type: FIELD_TYPES.TEXT, + label: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnitField', + { + defaultMessage: 'Time unit', + } + ), + }, + infiniteRetentionPeriod: { + type: FIELD_TYPES.TOGGLE, + defaultValue: false, + label: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.infiniteRetentionPeriodField', + { + defaultMessage: 'Keep data indefinitely', + } + ), + }, +}; + +export const EditDataRetentionModal: React.FunctionComponent = ({ + dataRetention, + dataStreamName, + onClose, +}) => { + const { size, unit } = splitSizeAndUnits(dataRetention); + const { + services: { notificationService }, + } = useAppContext(); + + const { form } = useForm({ + defaultValue: { + dataRetention: size, + timeUnit: unit || 'd', + infiniteRetentionPeriod: !dataRetention, + }, + schema: configurationFormSchema, + id: 'editDataRetentionForm', + }); + const [formData] = useFormData({ form }); + const isDirty = useFormIsModified({ form }); + + const onSubmitForm = async () => { + const { isValid, data } = await form.submit(); + + if (!isValid) { + return; + } + + return updateDataRetention(dataStreamName, data).then(({ data: responseData, error }) => { + if (responseData) { + const successMessage = i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.successDataRetentionNotification', + { defaultMessage: 'Data retention updated' } + ); + notificationService.showSuccessToast(successMessage); + + return onClose({ hasUpdatedDataRetention: true }); + } + + if (error) { + const errorMessage = i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.errorDataRetentionNotification', + { + defaultMessage: "Error updating data retention: '{error}'", + values: { error: error.message }, + } + ); + notificationService.showDangerToast(errorMessage); + } + + onClose(); + }); + }; + + return ( + onClose()} data-test-subj="editDataRetentionModal"> +
+ + + + + + + + + + {i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.learnMoreLinkText', + { + defaultMessage: 'How does it work?', + } + )} + + + } + componentProps={{ + fullWidth: false, + euiFieldProps: { + disabled: formData.infiniteRetentionPeriod, + 'data-test-subj': `dataRetentionValue`, + min: 1, + append: ( + + ), + }, + }} + /> + + + + + + + + onClose()}> + + + + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/index.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/index.ts new file mode 100644 index 000000000000..e17637adeaea --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { EditDataRetentionModal } from './edit_data_retention_modal'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/unit_field.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/unit_field.tsx new file mode 100644 index 000000000000..fc12b2ce9eda --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/unit_field.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent, useState } from 'react'; +import { EuiFilterSelectItem, EuiPopover, EuiButtonEmpty } from '@elastic/eui'; +import { UseField } from '../../../../../shared_imports'; + +interface Props { + path: string; + disabled?: boolean; + euiFieldProps?: Record; + options: Array<{ + value: string; + text: string; + }>; +} + +export const UnitField: FunctionComponent = ({ path, disabled, options, euiFieldProps }) => { + const [open, setOpen] = useState(false); + + return ( + + {(field) => { + const onSelect = (option: string) => { + field.setValue(option); + setOpen(false); + }; + + return ( + setOpen((isOpen) => !isOpen)} + data-test-subj="show-filters-button" + disabled={disabled} + > + {options.find((timeUnitOption) => timeUnitOption.value === field.value)?.text ?? + `${field.value}`} + + } + ownFocus + panelPaddingSize="none" + isOpen={open} + closePopover={() => setOpen(false)} + {...euiFieldProps} + > + {options.map((item) => ( + onSelect(item.value)} + data-test-subj={`filter-option-${item.value}`} + > + {item.text} + + ))} + + ); + }} + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index fb6ed8fd7772..54391aaaedf4 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -84,6 +84,19 @@ export async function deleteDataStreams(dataStreams: string[]) { }); } +export async function updateDataRetention( + name: string, + data: { dataRetention: string; timeUnit: string; infiniteRetentionPeriod: boolean } +) { + return sendRequest({ + path: `${API_BASE_PATH}/data_streams/${encodeURIComponent(name)}/data_retention`, + method: 'put', + body: data.infiniteRetentionPeriod + ? {} + : { dataRetention: `${data.dataRetention}${data.timeUnit}` }, + }); +} + export async function loadIndices() { const response = await httpService.httpClient.get(`${API_BASE_PATH}/indices`); return response.data ? response.data : response; diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index 8dcf17b202d2..80e3e44e4b3d 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -64,6 +64,7 @@ class DocumentationService { private bulkIndexAlias: string = ''; private indexStats: string = ''; private bulkApi: string = ''; + private updateExistingDS: string = ''; public setup(docLinks: DocLinksStart): void { const { links } = docLinks; @@ -121,6 +122,7 @@ class DocumentationService { this.bulkIndexAlias = links.apis.bulkIndexAlias; this.indexStats = links.apis.indexStats; this.bulkApi = links.enterpriseSearch.bulkApi; + this.updateExistingDS = links.elasticsearch.tutorialUpdateExistingDataStream; } public getEsDocsBase() { @@ -341,6 +343,10 @@ class DocumentationService { return this.bulkApi; } + public getUpdateExistingDS() { + return this.updateExistingDS; + } + public getWellKnownTextLink() { return 'http://docs.opengeospatial.org/is/12-063r5/12-063r5.html'; } diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index f443aa593f03..8ed089efb34a 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -40,6 +40,7 @@ export { VALIDATION_TYPES, useForm, useFormData, + useFormIsModified, Form, getUseField, UseField, @@ -60,6 +61,7 @@ export { TextField, SelectField, ToggleField, + NumericField, JsonEditorField, ComboBoxField, } from '@kbn/es-ui-shared-plugin/static/forms/components'; diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/data_streams.test.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/data_streams.test.ts new file mode 100644 index 000000000000..87d5a8fbee1d --- /dev/null +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/data_streams.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { addBasePath } from '..'; +import { RouterMock, routeDependencies, RequestMock } from '../../../test/helpers'; + +import { registerDataStreamRoutes } from '.'; + +describe('Data streams API', () => { + const router = new RouterMock(); + + beforeEach(() => { + registerDataStreamRoutes({ + ...routeDependencies, + router, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Update data retention for DS - PUT /internal/index_management/{name}/data_retention', () => { + const updateDataLifecycle = router.getMockESApiFn('indices.putDataLifecycle'); + + it('updates data lifecycle for a given data stream', async () => { + const mockRequest: RequestMock = { + method: 'put', + path: addBasePath('/data_streams/{name}/data_retention'), + params: { name: 'foo' }, + body: { dataRetention: '7d' }, + }; + + updateDataLifecycle.mockResolvedValue({ success: true }); + + const res = await router.runRequest(mockRequest); + + expect(res).toEqual({ + body: { success: true }, + }); + }); + + it('should return an error if it fails', async () => { + const mockRequest: RequestMock = { + method: 'put', + path: addBasePath('/data_streams/{name}/data_retention'), + params: { name: 'foo' }, + body: { dataRetention: '7d' }, + }; + + const error = new Error('Oh no!'); + updateDataLifecycle.mockRejectedValue(error); + + await expect(router.runRequest(mockRequest)).rejects.toThrowError(error); + }); + }); +}); diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts index 39238c410489..10ef4ae34dd0 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts @@ -9,6 +9,7 @@ import { RouteDependencies } from '../../../types'; import { registerGetOneRoute, registerGetAllRoute } from './register_get_route'; import { registerDeleteRoute } from './register_delete_route'; +import { registerPutDataRetention } from './register_put_route'; import { registerPostOneApplyLatestMappings, registerPostOneRollover } from './register_post_route'; export function registerDataStreamRoutes(dependencies: RouteDependencies) { @@ -17,4 +18,5 @@ export function registerDataStreamRoutes(dependencies: RouteDependencies) { registerPostOneRollover(dependencies); registerGetAllRoute(dependencies); registerDeleteRoute(dependencies); + registerPutDataRetention(dependencies); } diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts index 0b33fb2a2b5a..8ca301e6bee0 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts @@ -34,6 +34,9 @@ const enhanceDataStreams = ({ delete_index: dataStreamsPrivileges ? dataStreamsPrivileges.index[dataStream.name].delete_index : true, + manage_data_stream_lifecycle: dataStreamsPrivileges + ? dataStreamsPrivileges.index[dataStream.name].manage_data_stream_lifecycle + : true, }, }; @@ -73,7 +76,7 @@ const getDataStreamsPrivileges = (client: IScopedClusterClient, names: string[]) index: [ { names, - privileges: ['delete_index'], + privileges: ['delete_index', 'manage_data_stream_lifecycle'], }, ], }, diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_put_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_put_route.ts new file mode 100644 index 000000000000..cba52ce9101d --- /dev/null +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_put_route.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '..'; + +export function registerPutDataRetention({ router, lib: { handleEsError } }: RouteDependencies) { + const paramsSchema = schema.object({ + name: schema.string(), + }); + const bodySchema = schema.object({ + dataRetention: schema.maybe(schema.string()), + }); + + router.put( + { + path: addBasePath('/data_streams/{name}/data_retention'), + validate: { params: paramsSchema, body: bodySchema }, + }, + async (context, request, response) => { + const { name } = request.params as TypeOf; + const { dataRetention } = request.body as TypeOf; + + const { client } = (await context.core).elasticsearch; + + try { + await client.asCurrentUser.indices.putDataLifecycle({ + name, + data_retention: dataRetention, + }); + + return response.ok({ body: { success: true } }); + } catch (error) { + return handleEsError({ error, response }); + } + } + ); +} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 11872fd12e24..6fc1efc1ade5 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -17315,7 +17315,6 @@ "xpack.idxMgmt.createTemplate.createLegacyTemplatePageTitle": "Créer un modèle hérité", "xpack.idxMgmt.createTemplate.createTemplatePageTitle": "Créer un modèle", "xpack.idxMgmt.dataStreamDetailPanel.closeButtonLabel": "Fermer", - "xpack.idxMgmt.dataStreamDetailPanel.deleteButtonLabel": "Supprimer le flux de données", "xpack.idxMgmt.dataStreamDetailPanel.generationTitle": "Génération", "xpack.idxMgmt.dataStreamDetailPanel.generationToolTip": "Nombre cumulatif d'index de sauvegarde créés pour le flux de données", "xpack.idxMgmt.dataStreamDetailPanel.healthTitle": "Intégrité", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a48765228124..5af5cdb62265 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17329,7 +17329,6 @@ "xpack.idxMgmt.createTemplate.createLegacyTemplatePageTitle": "レガシーテンプレートの作成", "xpack.idxMgmt.createTemplate.createTemplatePageTitle": "テンプレートを作成", "xpack.idxMgmt.dataStreamDetailPanel.closeButtonLabel": "閉じる", - "xpack.idxMgmt.dataStreamDetailPanel.deleteButtonLabel": "データストリームを削除", "xpack.idxMgmt.dataStreamDetailPanel.generationTitle": "生成", "xpack.idxMgmt.dataStreamDetailPanel.generationToolTip": "データストリームに作成されたバッキングインデックスの累積数", "xpack.idxMgmt.dataStreamDetailPanel.healthTitle": "ヘルス", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e2019067a5ea..563baadf3bd1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17329,7 +17329,6 @@ "xpack.idxMgmt.createTemplate.createLegacyTemplatePageTitle": "创建旧版模板", "xpack.idxMgmt.createTemplate.createTemplatePageTitle": "创建模板", "xpack.idxMgmt.dataStreamDetailPanel.closeButtonLabel": "关闭", - "xpack.idxMgmt.dataStreamDetailPanel.deleteButtonLabel": "删除数据流", "xpack.idxMgmt.dataStreamDetailPanel.generationTitle": "世代", "xpack.idxMgmt.dataStreamDetailPanel.generationToolTip": "为数据流创建的后备索引的累积计数", "xpack.idxMgmt.dataStreamDetailPanel.healthTitle": "运行状况", diff --git a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts index 520396ad4628..46c7d299be4e 100644 --- a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts +++ b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts @@ -117,6 +117,7 @@ export default function ({ getService }: FtrProviderContext) { }, privileges: { delete_index: true, + manage_data_stream_lifecycle: true, }, timeStampField: { name: '@timestamp' }, indices: [ @@ -156,6 +157,7 @@ export default function ({ getService }: FtrProviderContext) { name: testDataStreamName, privileges: { delete_index: true, + manage_data_stream_lifecycle: true, }, timeStampField: { name: '@timestamp' }, indices: [ @@ -190,6 +192,7 @@ export default function ({ getService }: FtrProviderContext) { name: testDataStreamName, privileges: { delete_index: true, + manage_data_stream_lifecycle: true, }, timeStampField: { name: '@timestamp' }, indices: [ @@ -210,6 +213,35 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('Update', () => { + const testDataStreamName = 'test-data-stream'; + + before(async () => await createDataStream(testDataStreamName)); + after(async () => await deleteDataStream(testDataStreamName)); + + it('updates the data retention of a DS', async () => { + const { body } = await supertest + .put(`${API_BASE_PATH}/data_streams/${testDataStreamName}/data_retention`) + .set('kbn-xsrf', 'xxx') + .send({ + dataRetention: '7d', + }) + .expect(200); + + expect(body).to.eql({ success: true }); + }); + + it('sets data retention to infinite', async () => { + const { body } = await supertest + .put(`${API_BASE_PATH}/data_streams/${testDataStreamName}/data_retention`) + .set('kbn-xsrf', 'xxx') + .send({}) + .expect(200); + + expect(body).to.eql({ success: true }); + }); + }); + describe('Delete', () => { const testDataStreamName1 = 'test-data-stream1'; const testDataStreamName2 = 'test-data-stream2'; diff --git a/x-pack/test/functional/apps/index_management/data_streams_tab/data_streams_tab.ts b/x-pack/test/functional/apps/index_management/data_streams_tab/data_streams_tab.ts new file mode 100644 index 000000000000..c801c40c7e06 --- /dev/null +++ b/x-pack/test/functional/apps/index_management/data_streams_tab/data_streams_tab.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'indexManagement', 'header']); + const toasts = getService('toasts'); + const log = getService('log'); + const dataStreams = getService('dataStreams'); + const browser = getService('browser'); + const security = getService('security'); + const testSubjects = getService('testSubjects'); + + const TEST_DS_NAME = 'test-ds-1'; + + describe('Data streams tab', function () { + before(async () => { + await log.debug('Creating required data stream'); + try { + await dataStreams.createDataStream( + TEST_DS_NAME, + { + '@timestamp': { + type: 'date', + }, + }, + false + ); + } catch (e) { + log.debug('[Setup error] Error creating test data stream'); + throw e; + } + + await log.debug('Navigating to the data streams tab'); + await security.testUser.setRoles(['index_management_user']); + await pageObjects.common.navigateToApp('indexManagement'); + // Navigate to the data streams tab + await pageObjects.indexManagement.changeTabs('data_streamsTab'); + await pageObjects.header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await log.debug('Cleaning up created data stream'); + + try { + await dataStreams.deleteDataStream(TEST_DS_NAME); + } catch (e) { + log.debug('[Teardown error] Error deleting test data stream'); + throw e; + } + }); + + it('shows the details flyout when clicking on a data stream', async () => { + // Open details flyout + await pageObjects.indexManagement.clickDataStreamAt(0); + // Verify url is stateful + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/data_streams/${TEST_DS_NAME}`); + // Assert that flyout is opened + expect(await testSubjects.exists('dataStreamDetailPanel')).to.be(true); + // Close flyout + await testSubjects.click('closeDetailsButton'); + }); + + it('allows to update data retention', async () => { + // Open details flyout + await pageObjects.indexManagement.clickDataStreamAt(0); + // Open the edit retention dialog + await testSubjects.click('manageDataStreamButton'); + await testSubjects.click('editDataRetentionButton'); + + // Disable infinite retention + await testSubjects.click('infiniteRetentionPeriod > input'); + // Set the retention to 7 hours + await testSubjects.setValue('dataRetentionValue', '7'); + await testSubjects.click('show-filters-button'); + await testSubjects.click('filter-option-h'); + + // Submit the form + await testSubjects.click('saveButton'); + + // Expect to see a success toast + const successToast = await toasts.getToastElement(1); + expect(await successToast.getVisibleText()).to.contain('Data retention updated'); + }); + }); +}; diff --git a/x-pack/test/functional/apps/index_management/data_streams_tab/index.ts b/x-pack/test/functional/apps/index_management/data_streams_tab/index.ts new file mode 100644 index 000000000000..e8880ae84559 --- /dev/null +++ b/x-pack/test/functional/apps/index_management/data_streams_tab/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default ({ loadTestFile }: FtrProviderContext) => { + describe('Index Management: data streams tab', function () { + loadTestFile(require.resolve('./data_streams_tab')); + }); +}; diff --git a/x-pack/test/functional/apps/index_management/index.ts b/x-pack/test/functional/apps/index_management/index.ts index df9337ba284c..c43f8b9688da 100644 --- a/x-pack/test/functional/apps/index_management/index.ts +++ b/x-pack/test/functional/apps/index_management/index.ts @@ -15,5 +15,6 @@ export default ({ loadTestFile }: FtrProviderContext) => { loadTestFile(require.resolve('./index_details_page')); loadTestFile(require.resolve('./enrich_policies_tab')); loadTestFile(require.resolve('./create_enrich_policy')); + loadTestFile(require.resolve('./data_streams_tab')); }); }; diff --git a/x-pack/test/functional/page_objects/index_management_page.ts b/x-pack/test/functional/page_objects/index_management_page.ts index b86aa84b4944..30f53f46e659 100644 --- a/x-pack/test/functional/page_objects/index_management_page.ts +++ b/x-pack/test/functional/page_objects/index_management_page.ts @@ -34,6 +34,11 @@ export function IndexManagementPageProvider({ getService }: FtrProviderContext) await policyDetailsLinks[indexOfRow].click(); }, + async clickDataStreamAt(indexOfRow: number): Promise { + const dataStreamLinks = await testSubjects.findAll('nameLink'); + await dataStreamLinks[indexOfRow].click(); + }, + async clickDeleteEnrichPolicyAt(indexOfRow: number): Promise { const deleteButons = await testSubjects.findAll('deletePolicyButton'); await deleteButons[indexOfRow].click(); diff --git a/x-pack/test_serverless/functional/test_suites/common/management/index_management/data_streams.ts b/x-pack/test_serverless/functional/test_suites/common/management/index_management/data_streams.ts new file mode 100644 index 000000000000..88cdcba00d18 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/common/management/index_management/data_streams.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['svlCommonPage', 'common', 'indexManagement', 'header']); + const browser = getService('browser'); + const security = getService('security'); + const log = getService('log'); + const es = getService('es'); + const testSubjects = getService('testSubjects'); + const toasts = getService('toasts'); + + const TEST_DS_NAME = 'test-ds-1'; + + describe('Data Streams', function () { + before(async () => { + await log.debug('Creating required data stream'); + try { + await es.cluster.putComponentTemplate({ + name: `${TEST_DS_NAME}_mapping`, + template: { + settings: { mode: undefined }, + mappings: { + properties: { + '@timestamp': { + type: 'date', + }, + }, + }, + }, + }); + + await es.indices.putIndexTemplate({ + name: `${TEST_DS_NAME}_index_template`, + index_patterns: [TEST_DS_NAME], + data_stream: {}, + composed_of: [`${TEST_DS_NAME}_mapping`], + _meta: { + description: `Template for ${TEST_DS_NAME} testing index`, + }, + }); + + await es.indices.createDataStream({ + name: TEST_DS_NAME, + }); + } catch (e) { + log.debug('[Setup error] Error creating test data stream'); + throw e; + } + + await security.testUser.setRoles(['index_management_user']); + // Navigate to the index management page + await pageObjects.svlCommonPage.login(); + await pageObjects.common.navigateToApp('indexManagement'); + // Navigate to the indices tab + await pageObjects.indexManagement.changeTabs('data_streamsTab'); + await pageObjects.header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await log.debug('Cleaning up created data stream'); + + try { + await es.indices.deleteDataStream({ name: TEST_DS_NAME }); + await es.indices.deleteIndexTemplate({ + name: `${TEST_DS_NAME}_index_template`, + }); + await es.cluster.deleteComponentTemplate({ + name: `${TEST_DS_NAME}_mapping`, + }); + } catch (e) { + log.debug('[Teardown error] Error deleting test data stream'); + throw e; + } + }); + + it('renders the data streams tab', async () => { + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/data_streams`); + }); + + it('shows the details flyout when clicking on a data stream', async () => { + // Open details flyout + await pageObjects.indexManagement.clickDataStreamAt(0); + // Verify url is stateful + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/data_streams/${TEST_DS_NAME}`); + // Assert that flyout is opened + expect(await testSubjects.exists('dataStreamDetailPanel')).to.be(true); + // Close flyout + await testSubjects.click('closeDetailsButton'); + }); + + it('allows to update data retention', async () => { + // Open details flyout + await pageObjects.indexManagement.clickDataStreamAt(0); + // Open the edit retention dialog + await testSubjects.click('manageDataStreamButton'); + await testSubjects.click('editDataRetentionButton'); + + // Disable infinite retention + await testSubjects.click('infiniteRetentionPeriod > input'); + // Set the retention to 7 hours + await testSubjects.setValue('dataRetentionValue', '7'); + await testSubjects.click('show-filters-button'); + await testSubjects.click('filter-option-h'); + + // Submit the form + await testSubjects.click('saveButton'); + + // Expect to see a success toast + const successToast = await toasts.getToastElement(1); + expect(await successToast.getVisibleText()).to.contain('Data retention updated'); + }); + }); +};