diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 458e4ab1e73e5..3c7628ebbdd67 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -22141,7 +22141,6 @@ "xpack.idxMgmt.dataStreamList.table.actionDeleteText": "Supprimer", "xpack.idxMgmt.dataStreamList.table.dataRetentionColumnTitle": "Conservation des données", "xpack.idxMgmt.dataStreamList.table.dataRetentionColumnTooltip": "Les données sont conservées au moins pour cette durée avant leur suppression automatique. La valeur de rétention de données s'applique uniquement aux données gérées directement par le flux de données. {canDisableDataRetention, plural, one {Si certaines données sont sujettes à une politique de gestion du cycle de vie de l'index, alors la valeur de conservation des données réglée pour le flux de données ne s'applique par à ces données.} other {}}", - "xpack.idxMgmt.dataStreamList.table.deleteDataStreamsButtonLabel": "Supprimer {count, plural, one {le flux de données} other {les flux de données} }", "xpack.idxMgmt.dataStreamList.table.healthColumnTitle": "Intégrité", "xpack.idxMgmt.dataStreamList.table.hiddenDataStreamBadge": "Masqué", "xpack.idxMgmt.dataStreamList.table.indicesColumnTitle": "Index", @@ -22157,22 +22156,16 @@ "xpack.idxMgmt.dataStreamListControls.includeStatsSwitchLabel": "Statistiques incluses", "xpack.idxMgmt.dataStreamListControls.includeStatsSwitchToolTip": "L'inclusion de statistiques peut augmenter le temps de rechargement", "xpack.idxMgmt.dataStreamListDescription.learnMoreLinkText": "En savoir plus.", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.cancelButtonLabel": "Annuler", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionEnabledField": "Activer la conservation des données", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionField": "Conservation des données", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldNonNegativeError": "Une valeur positive est requise.", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldRequiredError": "Une valeur de conservation des données est requise.", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.errorDataRetentionNotification": "Erreur lors de la mise à niveau de la conservation des données : \"{error}\"", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.fullyManagedByILMButtonLabel": "Stratégie ILM", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.fullyManagedByILMDescription": "Afin de modifier la conservation des données pour ce flux de données, vous devez modifier le {link} associé.", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.fullyManagedByILMTitle": "Ce flux de données et les index associés sont gérés par la stratégie ILM", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.learnMoreLinkText": "Comment ça fonctionne ?", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.saveButtonLabel": "Enregistrer", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.someManagedByILMBody": "Un index ou plus sont gérés par une politique ILM ({viewAllIndicesLink}). La mise à niveau de la conservation des données pour ce flux de données n'aura pas d'incidence sur ces index. À la place, vous devrez mettre à niveau la politique {ilmPolicyLink}.", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.someManagedByILMTitle": "Certains index sont gérés par la stratégie ILM", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.successDataRetentionNotification": "Conservation des données {disabledDataRetention, plural, one { désactivée } other { mise à niveau } }", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnitField": "Unité de temps", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.unitsAriaLabel": "Unité de temps", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.viewAllIndices": "afficher les index", "xpack.idxMgmt.dataStreamsDetailsPanel.manageButtonLabel": "Gérer", "xpack.idxMgmt.dataStreamsDetailsPanel.stepLogistics.dataRetentionFieldDecimalError": "La valeur doit être un nombre entier.", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index f396226fdf560..75df793165671 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -21999,7 +21999,6 @@ "xpack.idxMgmt.dataStreamList.table.actionDeleteText": "削除", "xpack.idxMgmt.dataStreamList.table.dataRetentionColumnTitle": "データ保持", "xpack.idxMgmt.dataStreamList.table.dataRetentionColumnTooltip": "データは少なくともこの期間保存された後、自動的に削除されます。データ保持値は、データストリームによって直接管理されたデータにのみ適用されます。{canDisableDataRetention, plural, one {一部のデータにインデックスライフサイクル管理ポリシーが適用される場合、データストリームに設定されたデータ保持値はそのデータに適用されません。} other {}}", - "xpack.idxMgmt.dataStreamList.table.deleteDataStreamsButtonLabel": "{count, plural, other {個のデータストリーム}}を削除", "xpack.idxMgmt.dataStreamList.table.healthColumnTitle": "ヘルス", "xpack.idxMgmt.dataStreamList.table.hiddenDataStreamBadge": "非表示", "xpack.idxMgmt.dataStreamList.table.indicesColumnTitle": "インデックス", @@ -22015,22 +22014,16 @@ "xpack.idxMgmt.dataStreamListControls.includeStatsSwitchLabel": "統計情報を含める", "xpack.idxMgmt.dataStreamListControls.includeStatsSwitchToolTip": "統計情報を含めると、再読み込み時間が長くなることがあります", "xpack.idxMgmt.dataStreamListDescription.learnMoreLinkText": "詳細情報", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.cancelButtonLabel": "キャンセル", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionEnabledField": "データ保持を有効化", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionField": "データ保持", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldNonNegativeError": "正の値が必要です。", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldRequiredError": "データ保持値が必要です。", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.errorDataRetentionNotification": "データ保持の更新エラー:''{error}''", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.fullyManagedByILMButtonLabel": "ILMポリシー", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.fullyManagedByILMDescription": "このデータストリームのデータ保持を編集するには、関連する{link}を編集する必要があります。", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.fullyManagedByILMTitle": "このデータストリームと関連するインデックスはILMによって管理されます。", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.learnMoreLinkText": "仕組み", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.saveButtonLabel": "保存", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.someManagedByILMBody": "ILMポリシー({viewAllIndicesLink})によって1つ以上のインデックスが管理されます。このデータストリームのデータ保持を更新しても、これらのインデックスには影響しません。代わりに、{ilmPolicyLink}ポリシーを更新する必要があります。", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.someManagedByILMTitle": "一部のインデックスはILMによって管理されます。", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.successDataRetentionNotification": "データ保持が{disabledDataRetention, plural, one {無効化されました} other {更新されました} }", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnitField": "時間単位", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.unitsAriaLabel": "時間単位", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.viewAllIndices": "インデックスを表示", "xpack.idxMgmt.dataStreamsDetailsPanel.manageButtonLabel": "管理", "xpack.idxMgmt.dataStreamsDetailsPanel.stepLogistics.dataRetentionFieldDecimalError": "値は整数でなければなりません。", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 73ef6cc59c976..59207bbfc580b 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -21653,7 +21653,6 @@ "xpack.idxMgmt.dataStreamList.table.actionDeleteText": "删除", "xpack.idxMgmt.dataStreamList.table.dataRetentionColumnTitle": "数据保留", "xpack.idxMgmt.dataStreamList.table.dataRetentionColumnTooltip": "会至少在这个时长内保留数据,然后自动将其删除。数据保留值仅适用于由数据流直接管理的数据。{canDisableDataRetention, plural, one {如果某些数据受索引生命周期管理策略约束,则为数据流设置的数据保留值不适用于该数据。} other {}}", - "xpack.idxMgmt.dataStreamList.table.deleteDataStreamsButtonLabel": "删除{count, plural, other {数据流} }", "xpack.idxMgmt.dataStreamList.table.healthColumnTitle": "运行状况", "xpack.idxMgmt.dataStreamList.table.hiddenDataStreamBadge": "隐藏", "xpack.idxMgmt.dataStreamList.table.indicesColumnTitle": "索引", @@ -21669,7 +21668,6 @@ "xpack.idxMgmt.dataStreamListControls.includeStatsSwitchLabel": "包含统计信息", "xpack.idxMgmt.dataStreamListControls.includeStatsSwitchToolTip": "包含统计信息可能会延长重新加载时间", "xpack.idxMgmt.dataStreamListDescription.learnMoreLinkText": "了解详情。", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.cancelButtonLabel": "取消", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionEnabledField": "启用数据保留", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionField": "数据保留", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldNonNegativeError": "需要提供正值。", @@ -21677,13 +21675,9 @@ "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.fullyManagedByILMButtonLabel": "ILM 策略", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.fullyManagedByILMDescription": "要编辑此数据流的数据保留,必须编辑其关联 {link}。", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.fullyManagedByILMTitle": "此数据流及其关联索引由 ILM 管理", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.learnMoreLinkText": "工作原理?", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.saveButtonLabel": "保存", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.someManagedByILMBody": "一个或多个索引由 ILM 策略管理 ({viewAllIndicesLink})。更新此数据流的数据保留不会影响到这些索引。相反,您必须更新 {ilmPolicyLink} 策略。", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.someManagedByILMTitle": "某些索引由 ILM 管理", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.successDataRetentionNotification": "数据保留{disabledDataRetention, plural, one {已禁用} other {已更新} }", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnitField": "时间单位", - "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.unitsAriaLabel": "时间单位", "xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.viewAllIndices": "查看索引", "xpack.idxMgmt.dataStreamsDetailsPanel.manageButtonLabel": "管理", "xpack.idxMgmt.dataStreamsDetailsPanel.stepLogistics.dataRetentionFieldDecimalError": "此值应为整数。", 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 608d2ce5390da..b75d1c507cc7a 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 @@ -37,6 +37,8 @@ export interface DataStreamsTabTestBed extends TestBed { clickDeleteDataStreamButton: () => void; clickEditDataRetentionButton: () => void; clickDetailPanelIndexTemplateLink: () => void; + clickManageDataStreamsButton: () => void; + clickBulkEditDataRetentionButton: () => void; }; findDeleteActionAt: (index: number) => ReactWrapper; findDeleteConfirmationModal: () => ReactWrapper; @@ -210,6 +212,14 @@ export const setup = async ( component.update(); }; + const clickManageDataStreamsButton = () => { + testBed.find('dataStreamActionsPopoverButton').simulate('click'); + }; + + const clickBulkEditDataRetentionButton = () => { + testBed.find('bulkEditDataRetentionButton').simulate('click'); + }; + const findDetailPanel = () => { const { find } = testBed; return find('dataStreamDetailPanel'); @@ -258,6 +268,8 @@ export const setup = async ( clickDeleteDataStreamButton, clickEditDataRetentionButton, clickDetailPanelIndexTemplateLink, + clickManageDataStreamsButton, + clickBulkEditDataRetentionButton, }, findDeleteActionAt, findDeleteConfirmationModal, 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 3bc122ad867f6..d3368371de336 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 @@ -449,6 +449,158 @@ describe('Data Streams tab', () => { }); }); + describe('bulk update data retention', () => { + beforeAll(async () => { + const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; + + const ds1 = createDataStreamPayload({ + name: 'dataStream1', + lifecycle: { + enabled: false, + }, + }); + const ds2 = createDataStreamPayload({ + name: 'dataStream2', + lifecycle: { + enabled: true, + }, + }); + + setLoadDataStreamsResponse([ds1, ds2]); + setLoadDataStreamResponse(ds1.name, ds1); + + testBed = await setup(httpSetup, { + history: createMemoryHistory(), + url: urlServiceMock, + }); + await act(async () => { + testBed.actions.goToDataStreamsList(); + }); + testBed.component.update(); + }); + + test('can set data retention period for mutliple data streams', async () => { + const { + actions: { + selectDataStream, + clickManageDataStreamsButton, + clickBulkEditDataRetentionButton, + }, + } = testBed; + + selectDataStream('dataStream1', true); + selectDataStream('dataStream2', true); + clickManageDataStreamsButton(); + + clickBulkEditDataRetentionButton(); + + httpRequestsMockHelpers.setEditDataRetentionResponse('dataStream1', { + success: true, + }); + + httpRequestsMockHelpers.setEditDataRetentionResponse('dataStream2', { + 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/data_retention`, + expect.objectContaining({ + body: JSON.stringify({ + dataRetention: '7h', + dataStreams: ['dataStream1', 'dataStream2'], + }), + }) + ); + }); + + test('can disable lifecycle', async () => { + const { + actions: { + selectDataStream, + clickManageDataStreamsButton, + clickBulkEditDataRetentionButton, + }, + } = testBed; + + selectDataStream('dataStream1', true); + selectDataStream('dataStream2', true); + clickManageDataStreamsButton(); + + clickBulkEditDataRetentionButton(); + + httpRequestsMockHelpers.setEditDataRetentionResponse('dataStream1', { + success: true, + }); + + httpRequestsMockHelpers.setEditDataRetentionResponse('dataStream2', { + success: true, + }); + + testBed.form.toggleEuiSwitch('dataRetentionEnabledField.input'); + + await act(async () => { + testBed.find('saveButton').simulate('click'); + }); + testBed.component.update(); + + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/data_streams/data_retention`, + expect.objectContaining({ + body: JSON.stringify({ enabled: false, dataStreams: ['dataStream1', 'dataStream2'] }), + }) + ); + }); + + test('allows to set infinite retention period', async () => { + const { + actions: { + selectDataStream, + clickManageDataStreamsButton, + clickBulkEditDataRetentionButton, + }, + } = testBed; + + selectDataStream('dataStream1', true); + selectDataStream('dataStream2', true); + clickManageDataStreamsButton(); + + clickBulkEditDataRetentionButton(); + + httpRequestsMockHelpers.setEditDataRetentionResponse('dataStream1', { + success: true, + }); + + httpRequestsMockHelpers.setEditDataRetentionResponse('dataStream2', { + 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/data_retention`, + expect.objectContaining({ + body: JSON.stringify({ dataStreams: ['dataStream1', 'dataStream2'] }), + }) + ); + }); + }); + describe('detail panel', () => { test('opens when the data stream name in the table is clicked', async () => { const { actions, findDetailPanel, findDetailPanelTitle } = testBed; @@ -557,8 +709,10 @@ describe('Data Streams tab', () => { testBed.component.update(); expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}/data_streams/dataStream1/data_retention`, - expect.objectContaining({ body: JSON.stringify({ dataRetention: '7h' }) }) + `${API_BASE_PATH}/data_streams/data_retention`, + expect.objectContaining({ + body: JSON.stringify({ dataRetention: '7h', dataStreams: ['dataStream1'] }), + }) ); }); @@ -583,8 +737,10 @@ describe('Data Streams tab', () => { testBed.component.update(); expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}/data_streams/dataStream1/data_retention`, - expect.objectContaining({ body: JSON.stringify({ enabled: false }) }) + `${API_BASE_PATH}/data_streams/data_retention`, + expect.objectContaining({ + body: JSON.stringify({ enabled: false, dataStreams: ['dataStream1'] }), + }) ); }); @@ -609,8 +765,8 @@ describe('Data Streams tab', () => { testBed.component.update(); expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}/data_streams/dataStream1/data_retention`, - expect.objectContaining({ body: JSON.stringify({}) }) + `${API_BASE_PATH}/data_streams/data_retention`, + expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) }) ); }); }); @@ -664,6 +820,7 @@ describe('Data Streams tab', () => { enabled: true, data_retention: '7d', }, + ilmPolicyName: 'testILM', indices: [ { managedBy: 'Index Lifecycle Management', @@ -1028,17 +1185,20 @@ describe('Data Streams tab', () => { test('displays/hides delete action depending on data streams privileges', async () => { const { - actions: { selectDataStream }, + actions: { selectDataStream, clickManageDataStreamsButton }, find, } = testBed; selectDataStream('dataStreamNoDelete', true); + clickManageDataStreamsButton(); expect(find('deleteDataStreamsButton').exists()).toBeFalsy(); selectDataStream('dataStreamWithDelete', true); + clickManageDataStreamsButton(); expect(find('deleteDataStreamsButton').exists()).toBeFalsy(); selectDataStream('dataStreamNoDelete', false); + clickManageDataStreamsButton(); expect(find('deleteDataStreamsButton').exists()).toBeTruthy(); }); diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_actions_menu/data_stream_actions_menu.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_actions_menu/data_stream_actions_menu.tsx new file mode 100644 index 0000000000000..2464f7ac03e3c --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_actions_menu/data_stream_actions_menu.tsx @@ -0,0 +1,66 @@ +/* + * 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 { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiContextMenuPanelItemDescriptor } from '@elastic/eui/src/components/context_menu/context_menu'; +import { i18n } from '@kbn/i18n'; + +interface Props { + dataStreamActions: EuiContextMenuPanelItemDescriptor[]; + selectedDataStreamsCount: number; +} + +export const DataStreamActionsMenu = ({ dataStreamActions, selectedDataStreamsCount }: Props) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const popoverButton = ( + setIsPopoverOpen(!isPopoverOpen)} + iconType="arrowDown" + iconSide="right" + fill={true} + > + + + ); + + return ( + setIsPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="rightUp" + repositionOnScroll={true} + > + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_actions_menu/index.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_actions_menu/index.ts new file mode 100644 index 0000000000000..0eb726d3eb79f --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_actions_menu/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 { DataStreamActionsMenu } from './data_stream_actions_menu'; 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 10ef17c566241..0f09a47f43880 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 @@ -550,7 +550,8 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ }} ilmPolicyName={dataStream?.ilmPolicyName} ilmPolicyLink={ilmPolicyLink} - dataStream={dataStream} + dataStreams={[dataStream]} + isBulkEdit={false} /> )} diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx index 59daae719bf47..e419bda67aeae 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx @@ -24,6 +24,7 @@ import { import { ScopedHistory } from '@kbn/core/public'; import { useEuiTablePersist } from '@kbn/shared-ux-table-persist'; +import { EuiContextMenuPanelItemDescriptor } from '@elastic/eui/src/components/context_menu/context_menu'; import { MAX_DATA_RETENTION } from '../../../../../../common/constants'; import { useAppContext } from '../../../../app_context'; import { DataStream } from '../../../../../../common/types'; @@ -39,6 +40,8 @@ import { isDataStreamFullyManagedByILM } from '../../../../lib/data_streams'; import { indexModeLabels } from '../../../../lib/index_mode_labels'; import { FilterListButton, Filters } from '../../components'; import { type DataStreamFilterName } from '../data_stream_list'; +import { DataStreamActionsMenu } from '../data_stream_actions_menu'; +import { EditDataRetentionModal } from '../edit_data_retention_modal'; interface TableDataStream extends DataStream { isDataStreamFullyManagedByILM: boolean; @@ -70,6 +73,9 @@ export const DataStreamTable: React.FunctionComponent = ({ }) => { const [selection, setSelection] = useState([]); const [dataStreamsToDelete, setDataStreamsToDelete] = useState([]); + const [dataStreamsToEditDataRetention, setDataStreamsToEditDataRetention] = useState< + DataStream[] + >([]); const { config } = useAppContext(); const data = useMemo(() => { @@ -284,25 +290,40 @@ export const DataStreamTable: React.FunctionComponent = ({ onSelectionChange: setSelection, }; + const dataStreamActions: EuiContextMenuPanelItemDescriptor[] = [ + { + name: i18n.translate('xpack.idxMgmt.dataStreamList.table.bulkEditDataRetentionButtonLabel', { + defaultMessage: 'Edit data retention', + }), + icon: 'pencil', + onClick: () => setDataStreamsToEditDataRetention(selection), + 'data-test-subj': 'bulkEditDataRetentionButton', + }, + ]; + + if (selection.every((dataStream: DataStream) => dataStream.privileges.delete_index)) { + dataStreamActions.push({ + name: i18n.translate('xpack.idxMgmt.dataStreamList.table.deleteDataStreamsButtonLabel', { + defaultMessage: 'Delete data streams', + }), + icon: 'trash', + onClick: () => setDataStreamsToDelete(selection.map(({ name }: DataStream) => name)), + className: 'dataStreamsBulkDeleteButton', + 'data-test-subj': 'deleteDataStreamsButton', + }); + } + const searchConfig = { query: filters, box: { incremental: true, }, toolsLeft: - selection.length > 0 && - selection.every((dataStream: DataStream) => dataStream.privileges.delete_index) ? ( - setDataStreamsToDelete(selection.map(({ name }: DataStream) => name))} - color="danger" - > - - + selection.length > 0 ? ( + ) : undefined, toolsRight: [ @@ -365,6 +386,19 @@ export const DataStreamTable: React.FunctionComponent = ({ return ( <> + {dataStreamsToEditDataRetention && dataStreamsToEditDataRetention.length > 0 ? ( + { + if (res && res.hasUpdatedDataRetention) { + reload(); + } else { + setDataStreamsToEditDataRetention([]); + } + }} + dataStreams={dataStreamsToEditDataRetention} + isBulkEdit={true} + /> + ) : null} {dataStreamsToDelete && dataStreamsToDelete.length > 0 ? ( { 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 index b8f2593131663..55d8348400f5f 100644 --- 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 @@ -20,26 +20,20 @@ import { EuiCallOut, } from '@elastic/eui'; import { has } from 'lodash'; -import { ScopedHistory } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { isBiggerThanGlobalMaxRetention } from './validations'; +import { isRetentionBiggerThan } from './validations'; +import { editDataRetentionFormSchema } from './schema'; import { useForm, useFormData, useFormIsModified, Form, - fieldFormatters, - FormSchema, - FIELD_TYPES, UseField, ToggleField, NumericField, - fieldValidators, } from '../../../../../shared_imports'; -import { reactRouterNavigate } from '../../../../../shared_imports'; -import { getIndexListUri } from '../../../../services/routing'; import { documentationService } from '../../../../services/documentation'; import { splitSizeAndUnits, DataStream } from '../../../../../../common'; import { timeUnits } from '../../../../constants/time_units'; @@ -47,179 +41,36 @@ import { deserializeGlobalMaxRetention, isDSLWithILMIndices } from '../../../../ import { useAppContext } from '../../../../app_context'; import { UnitField } from '../../../../components/shared'; import { updateDataRetention } from '../../../../services/api'; +import { MixedIndicesCallout } from './mixed_indices_callout'; interface Props { - dataStream: DataStream; + dataStreams: DataStream[]; ilmPolicyName?: string; - ilmPolicyLink: string; + ilmPolicyLink?: string; onClose: (data?: { hasUpdatedDataRetention: boolean }) => void; + isBulkEdit: boolean; } -const configurationFormSchema: FormSchema = { - dataRetention: { - type: FIELD_TYPES.TEXT, - label: i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionField', - { - defaultMessage: 'Data retention period', - } - ), - formatters: [fieldFormatters.toInt], - validations: [ - { - validator: fieldValidators.isInteger({ - message: i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldIntegerError', - { - defaultMessage: 'Only integers are allowed.', - } - ), - }), - }, - { - validator: ({ value, formData, customData }) => { - // We only need to validate the data retention field if infiniteRetentionPeriod is set to false - if (!formData.infiniteRetentionPeriod) { - // If project level data retention is enabled, we need to enforce the global max retention - const { globalMaxRetention, enableProjectLevelRetentionChecks } = - customData.value as any; - if (enableProjectLevelRetentionChecks) { - return isBiggerThanGlobalMaxRetention(value, formData.timeUnit, globalMaxRetention); - } - } - }, - }, - { - validator: (args) => { - // We only need to validate the data retention field if infiniteRetentionPeriod is set to false - if (!args.formData.infiniteRetentionPeriod) { - return fieldValidators.emptyField( - i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldRequiredError', - { - defaultMessage: 'A data retention value is required.', - } - ) - )(args); - } - }, - }, - { - validator: (args) => { - // We only need to validate the data retention field if infiniteRetentionPeriod is set to false - if (!args.formData.infiniteRetentionPeriod) { - return fieldValidators.numberGreaterThanField({ - than: 0, - allowEquality: false, - message: i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldNonNegativeError', - { - defaultMessage: `A positive value is required.`, - } - ), - })(args); - } - }, - }, - ], - }, - timeUnit: { - type: FIELD_TYPES.TEXT, - label: i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnitField', - { - defaultMessage: 'Time unit', - } - ), - }, - infiniteRetentionPeriod: { - type: FIELD_TYPES.TOGGLE, - defaultValue: false, - }, - dataRetentionEnabled: { - type: FIELD_TYPES.TOGGLE, - defaultValue: false, - label: i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionEnabledField', - { - defaultMessage: 'Enable data retention', - } - ), - }, -}; - -interface MixedIndicesCalloutProps { - history: ScopedHistory; - ilmPolicyLink: string; - ilmPolicyName?: string; - dataStreamName: string; -} - -const MixedIndicesCallout = ({ - ilmPolicyLink, - ilmPolicyName, - dataStreamName, - history, -}: MixedIndicesCalloutProps) => { - const { core } = useAppContext(); - - return ( - -

- core.application.navigateToUrl(ilmPolicyLink)} - > - {ilmPolicyName} - - ), - viewAllIndicesLink: ( - - - - ), - }} - /> -

-
- ); -}; - export const EditDataRetentionModal: React.FunctionComponent = ({ - dataStream, + dataStreams, ilmPolicyName, ilmPolicyLink, onClose, + isBulkEdit, }) => { - const lifecycle = dataStream?.lifecycle; - const dataStreamName = dataStream?.name as string; + const lifecycle = dataStreams[0]?.lifecycle; + const isSingleDataStream = dataStreams.length === 1; - const { history } = useAppContext(); - const dslWithIlmIndices = isDSLWithILMIndices(dataStream); - const { size, unit } = splitSizeAndUnits(lifecycle?.data_retention as string); + const { + history, + plugins: { cloud }, + } = useAppContext(); + const dataStreamNames = dataStreams.map(({ name }: DataStream) => name as string); const globalMaxRetention = deserializeGlobalMaxRetention(lifecycle?.globalMaxRetention); + const { size, unit } = isSingleDataStream + ? splitSizeAndUnits(lifecycle?.data_retention as string) + : { size: undefined, unit: undefined }; + const { services: { notificationService }, config: { enableTogglingDataRetention, enableProjectLevelRetentionChecks }, @@ -229,13 +80,14 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ defaultValue: { dataRetention: size, timeUnit: unit || 'd', - dataRetentionEnabled: lifecycle?.enabled, + dataRetentionEnabled: isSingleDataStream ? lifecycle?.enabled : true, // When data retention is not set and lifecycle is enabled, is the only scenario in // which data retention will be infinite. If lifecycle isnt set or is not enabled, we // dont have inifinite data retention. - infiniteRetentionPeriod: lifecycle?.enabled && !lifecycle?.data_retention, + infiniteRetentionPeriod: + isSingleDataStream && lifecycle?.enabled && !lifecycle?.data_retention, }, - schema: configurationFormSchema, + schema: editDataRetentionFormSchema, id: 'editDataRetentionForm', }); const [formData] = useFormData({ form }); @@ -247,8 +99,10 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ // Whenever the timeUnit field changes, we need to re-validate // the dataRetention field useEffect(() => { - form.validateFields(['dataRetention']); - }, [formData.timeUnit, form]); + if (formData.dataRetention) { + form.validateFields(['dataRetention']); + } + }, [formData.timeUnit, form, formData.dataRetention]); const onSubmitForm = async () => { const { isValid, data } = await form.submit(); @@ -267,7 +121,7 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ data.dataRetentionEnabled = true; } - return updateDataRetention(dataStreamName, data).then(({ data: responseData, error }) => { + return updateDataRetention(dataStreamNames, data).then(({ data: responseData, error }) => { if (responseData) { // If the response came back with a warning from ES, rely on that for the // toast message. @@ -276,27 +130,45 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ return onClose({ hasUpdatedDataRetention: true }); } - const successMessage = i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.successDataRetentionNotification', - { - defaultMessage: - 'Data retention {disabledDataRetention, plural, one { disabled } other { updated } }', - values: { disabledDataRetention: !data.dataRetentionEnabled ? 1 : 0 }, - } - ); + const successMessage = isBulkEdit + ? i18n.translate( + 'xpack.idxMgmt.dataStreams.editDataRetentionModal.successBulkDataRetentionNotification', + { + defaultMessage: + 'Data retention has been updated for {dataStreamCount, plural, one {one data stream} other {{dataStreamCount} data streams}}.', + values: { dataStreamCount: dataStreams.length }, + } + ) + : i18n.translate( + 'xpack.idxMgmt.dataStreams.editDataRetentionModal.successDataRetentionNotification', + { + defaultMessage: + 'Data retention {disabledDataRetention, plural, one { disabled } other { updated } }', + values: { disabledDataRetention: !data.dataRetentionEnabled ? 1 : 0 }, + } + ); + 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 }, - } - ); + const errorMessage = isBulkEdit + ? i18n.translate( + 'xpack.idxMgmt.dataStreams.editDataRetentionModal.errorBulkDataRetentionNotification', + { + defaultMessage: + 'There was an error updating the retention period. Try again later.', + } + ) + : i18n.translate( + 'xpack.idxMgmt.dataStreams.editDataRetentionModal.errorDataRetentionNotification', + { + defaultMessage: "Error updating data retention: ''{error}''", + values: { error: error.message }, + } + ); notificationService.showDangerToast(errorMessage); } @@ -304,39 +176,61 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ }); }; + const affectedDataStreams = dataStreams + .filter( + (ds: DataStream) => + formData.dataRetention && + formData.timeUnit && + ((ds.lifecycle?.enabled && + !ds.lifecycle?.data_retention && + !ds.lifecycle?.effective_retention) || + (typeof ds.lifecycle?.data_retention === 'string' && + isRetentionBiggerThan( + ds.lifecycle.data_retention, + `${formData.dataRetention}${formData.timeUnit}` + )) || + (ds.lifecycle?.effective_retention && + isRetentionBiggerThan( + ds.lifecycle.effective_retention, + `${formData.dataRetention}${formData.timeUnit}` + ))) + ) + .map(({ name }: DataStream) => name); + return ( onClose()} data-test-subj="editDataRetentionModal" - css={{ minWidth: 450 }} + css={{ minWidth: isBulkEdit ? 650 : 450, maxWidth: 650 }} >
- {dslWithIlmIndices && ( + {!isBulkEdit && isDSLWithILMIndices(dataStreams[0]) && ( <> )} - {enableProjectLevelRetentionChecks && lifecycle?.globalMaxRetention && ( + {enableProjectLevelRetentionChecks && !isBulkEdit && lifecycle?.globalMaxRetention && ( <> = ({ {i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.learnMoreLinkText', + 'xpack.idxMgmt.dataStreams.editDataRetentionModal.learnMoreLinkText', { defaultMessage: 'How does this work?', } @@ -374,8 +268,31 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ } + helpText={ + isBulkEdit && + lifecycle?.globalMaxRetention && ( + + {i18n.translate( + 'xpack.idxMgmt.dataStreams.editDataRetentionModal.manageProjectSettingsLinkText', + { + defaultMessage: 'Manage project settings.', + } + )} + + ), + }} + /> + ) + } componentProps={{ - fullWidth: false, + fullWidth: isBulkEdit, euiFieldProps: { disabled: formData.infiniteRetentionPeriod || @@ -393,7 +310,7 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ euiFieldProps={{ 'data-test-subj': 'timeUnit', 'aria-label': i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.unitsAriaLabel', + 'xpack.idxMgmt.dataStreams.editDataRetentionModal.unitsAriaLabel', { defaultMessage: 'Time unit', } @@ -410,7 +327,7 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ component={ToggleField} data-test-subj="infiniteRetentionPeriod" label={i18n.translate( - 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.infiniteRetentionPeriodField', + 'xpack.idxMgmt.dataStreams.editDataRetentionModal.infiniteRetentionPeriodField', { defaultMessage: 'Keep data {withProjectLevelRetention, plural, one {up to maximum retention period} other {indefinitely}}', @@ -425,12 +342,47 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ /> + + {isBulkEdit && affectedDataStreams.length > 0 && !formData.infiniteRetentionPeriod && ( + +

+ +

+ {affectedDataStreams.length <= 10 && ( +

+ {affectedDataStreams.join(', ')}, + }} + /> +

+ )} +
+ )}
onClose()}> @@ -444,7 +396,7 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ onClick={onSubmitForm} > diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/mixed_indices_callout.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/mixed_indices_callout.tsx new file mode 100644 index 0000000000000..da9dd84b4247e --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/mixed_indices_callout.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import React from 'react'; +import { ScopedHistory } from '@kbn/core-application-browser'; +import { getIndexListUri } from '../../../../..'; +import { useAppContext } from '../../../../app_context'; + +interface MixedIndicesCalloutProps { + history: ScopedHistory; + ilmPolicyLink?: string; + ilmPolicyName?: string; + dataStreamName: string; +} + +export const MixedIndicesCallout = ({ + ilmPolicyLink, + ilmPolicyName, + dataStreamName, + history, +}: MixedIndicesCalloutProps) => { + const { core } = useAppContext(); + + return ( + +

+ core.application.navigateToUrl(ilmPolicyLink)} + > + {ilmPolicyName} + + ), + viewAllIndicesLink: ( + + + + ), + }} + /> +

+
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/schema.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/schema.ts new file mode 100644 index 0000000000000..075e9c763276b --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/schema.ts @@ -0,0 +1,110 @@ +/* + * 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 { FIELD_TYPES, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { i18n } from '@kbn/i18n'; +import { fieldFormatters, fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { isBiggerThanGlobalMaxRetention } from './validations'; + +export const editDataRetentionFormSchema: FormSchema = { + dataRetention: { + type: FIELD_TYPES.TEXT, + label: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionField', + { + defaultMessage: 'Data retention period', + } + ), + formatters: [fieldFormatters.toInt], + validations: [ + { + validator: fieldValidators.isInteger({ + message: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldIntegerError', + { + defaultMessage: 'Only integers are allowed.', + } + ), + }), + }, + { + validator: ({ value, formData, customData }) => { + // We only need to validate the data retention field if infiniteRetentionPeriod is set to false and dataRetentionEnabled is set to true + if (formData.dataRetentionEnabled !== false && !formData.infiniteRetentionPeriod) { + // If project level data retention is enabled, we need to enforce the global max retention + const { globalMaxRetention, enableProjectLevelRetentionChecks } = + customData.value as any; + if (enableProjectLevelRetentionChecks) { + return isBiggerThanGlobalMaxRetention(value, formData.timeUnit, globalMaxRetention); + } + } + }, + }, + { + validator: (args) => { + // We only need to validate the data retention field if infiniteRetentionPeriod is set to false and dataRetentionEnabled is set to true + if ( + args.formData.dataRetentionEnabled !== false && + !args.formData.infiniteRetentionPeriod + ) { + return fieldValidators.emptyField( + i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldRequiredError', + { + defaultMessage: 'A data retention value is required.', + } + ) + )(args); + } + }, + }, + { + validator: (args) => { + // We only need to validate the data retention field if infiniteRetentionPeriod is set to false and dataRetentionEnabled is set to true + if ( + args.formData.dataRetentionEnabled !== false && + !args.formData.infiniteRetentionPeriod + ) { + return fieldValidators.numberGreaterThanField({ + than: 0, + allowEquality: false, + message: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldNonNegativeError', + { + defaultMessage: `A positive value is required.`, + } + ), + })(args); + } + }, + }, + ], + }, + timeUnit: { + type: FIELD_TYPES.TEXT, + label: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.timeUnitField', + { + defaultMessage: 'Time unit', + } + ), + }, + infiniteRetentionPeriod: { + type: FIELD_TYPES.TOGGLE, + defaultValue: false, + }, + dataRetentionEnabled: { + type: FIELD_TYPES.TOGGLE, + defaultValue: false, + label: i18n.translate( + 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionEnabledField', + { + defaultMessage: 'Enable data retention', + } + ), + }, +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.ts index 8486f01fb5b44..d019c587fe448 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.ts @@ -40,7 +40,10 @@ const convertToSeconds = (value: string) => { } }; -const isRetentionBiggerThan = (valueA: string, valueB: string) => { +/* +True if the first retention period is bigger than the latter one. + */ +export const isRetentionBiggerThan = (valueA: string, valueB: string) => { const secondsA = convertToSeconds(valueA); const secondsB = convertToSeconds(valueB); 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 5e4f88ebb6b18..f2600220612e7 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -88,7 +88,7 @@ export async function deleteDataStreams(dataStreams: string[]) { } export async function updateDataRetention( - name: string, + dataStreams: string[], data: { dataRetention: string; timeUnit: string; @@ -99,15 +99,15 @@ export async function updateDataRetention( let body; if (!data.dataRetentionEnabled) { - body = { enabled: false }; + body = { enabled: false, dataStreams }; } else { body = data.infiniteRetentionPeriod - ? {} - : { dataRetention: `${data.dataRetention}${data.timeUnit}` }; + ? { dataStreams } + : { dataRetention: `${data.dataRetention}${data.timeUnit}`, dataStreams }; } return sendRequest({ - path: `${API_BASE_PATH}/data_streams/${encodeURIComponent(name)}/data_retention`, + path: `${API_BASE_PATH}/data_streams/data_retention`, method: 'put', body, }); diff --git a/x-pack/plugins/index_management/public/index.scss b/x-pack/plugins/index_management/public/index.scss index a8952764cc39b..be46c14176133 100644 --- a/x-pack/plugins/index_management/public/index.scss +++ b/x-pack/plugins/index_management/public/index.scss @@ -20,3 +20,7 @@ word-break: break-all; } } + +.dataStreamsBulkDeleteButton { + color: $euiColorDangerText; +} 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 index bb1df7bf51518..a8143304f2b92 100644 --- 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 @@ -25,15 +25,14 @@ describe('Data streams API', () => { jest.resetAllMocks(); }); - describe('Update data retention for DS - PUT /internal/index_management/{name}/data_retention', () => { + describe('Update data retention for DS - PUT /internal/index_management/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' }, + path: addBasePath('/data_streams/data_retention'), + body: { dataRetention: '7d', dataStreams: ['foo'] }, }; updateDataLifecycle.mockResolvedValue({ success: true }); @@ -48,9 +47,8 @@ describe('Data streams API', () => { 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' }, + path: addBasePath('/data_streams/data_retention'), + body: { dataRetention: '7d', dataStreams: ['foo'] }, }; const error = new Error('Oh no!'); 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 cd47b8cc9e0bb..d03c79e4f698c 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 @@ -168,11 +168,17 @@ export function registerGetAllRoute({ router, lib: { handleEsError }, config }: const { index_templates: indexTemplates } = await client.asCurrentUser.indices.getIndexTemplate(); + // Only take the lifecycle of the first data stream since all data streams have the same global retention period + const lifecycle = await getDataStreamLifecycle(client, dataStreams[0].name); + // @ts-ignore - TS doesn't know about the `global_retention` property yet + const globalMaxRetention = lifecycle?.global_retention?.max_retention; + const enhancedDataStreams = enhanceDataStreams({ dataStreams, dataStreamsStats, meteringStats, dataStreamsPrivileges, + globalMaxRetention, indexTemplates, }); 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 index 1f4930a9ec426..97ea662daaa14 100644 --- 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 @@ -20,29 +20,26 @@ export const getEsWarningText = (warning: string): string | null => { }; export function registerPutDataRetention({ router, lib: { handleEsError } }: RouteDependencies) { - const paramsSchema = schema.object({ - name: schema.string(), - }); const bodySchema = schema.object({ + dataStreams: schema.arrayOf(schema.string()), dataRetention: schema.maybe(schema.string()), enabled: schema.maybe(schema.boolean()), }); router.put( { - path: addBasePath('/data_streams/{name}/data_retention'), - validate: { params: paramsSchema, body: bodySchema }, + path: addBasePath('/data_streams/data_retention'), + validate: { body: bodySchema }, }, async (context, request, response) => { - const { name } = request.params as TypeOf; - const { dataRetention, enabled } = request.body as TypeOf; + const { dataStreams, dataRetention, enabled } = request.body as TypeOf; const { client } = (await context.core).elasticsearch; try { // Only when enabled is explicitly set to false, we delete the data retention policy. if (enabled === false) { - await client.asCurrentUser.indices.deleteDataLifecycle({ name }); + await client.asCurrentUser.indices.deleteDataLifecycle({ name: dataStreams }); } else { // Otherwise, we create or update the data retention policy. // @@ -51,7 +48,7 @@ export function registerPutDataRetention({ router, lib: { handleEsError } }: Rou // global data retention limit set. const { headers } = await client.asCurrentUser.indices.putDataLifecycle( { - name, + name: dataStreams, data_retention: dataRetention, }, { meta: true } diff --git a/x-pack/plugins/index_management/tsconfig.json b/x-pack/plugins/index_management/tsconfig.json index 48b40c9376157..185c0b112fc55 100644 --- a/x-pack/plugins/index_management/tsconfig.json +++ b/x-pack/plugins/index_management/tsconfig.json @@ -54,6 +54,7 @@ "@kbn/ml-error-utils", "@kbn/unsaved-changes-prompt", "@kbn/shared-ux-table-persist", + "@kbn/core-application-browser", ], "exclude": ["target/**/*"] } 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 2976d4eac03b4..6ea9c601f6cc3 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 @@ -182,17 +182,38 @@ export default function ({ getService }: FtrProviderContext) { }); describe('Update', () => { - const testDataStreamName = 'test-data-stream'; + const testDataStreamName1 = 'test-data-stream1'; + const testDataStreamName2 = 'test-data-stream2'; - before(async () => await createDataStream(testDataStreamName)); - after(async () => await deleteDataStream(testDataStreamName)); + before(async () => { + await createDataStream(testDataStreamName1); + await createDataStream(testDataStreamName2); + }); + after(async () => { + await deleteDataStream(testDataStreamName1); + await deleteDataStream(testDataStreamName2); + }); it('updates the data retention of a DS', async () => { const { body } = await supertest - .put(`${API_BASE_PATH}/data_streams/${testDataStreamName}/data_retention`) + .put(`${API_BASE_PATH}/data_streams/data_retention`) .set('kbn-xsrf', 'xxx') .send({ dataRetention: '7d', + dataStreams: [testDataStreamName1], + }) + .expect(200); + + expect(body).to.eql({ success: true }); + }); + + it('updates the data retention of multiple DS', async () => { + const { body } = await supertest + .put(`${API_BASE_PATH}/data_streams/data_retention`) + .set('kbn-xsrf', 'xxx') + .send({ + dataRetention: '7d', + dataStreams: [testDataStreamName1, testDataStreamName2], }) .expect(200); @@ -201,9 +222,11 @@ export default function ({ getService }: FtrProviderContext) { it('sets data retention to infinite', async () => { const { body } = await supertest - .put(`${API_BASE_PATH}/data_streams/${testDataStreamName}/data_retention`) + .put(`${API_BASE_PATH}/data_streams/data_retention`) .set('kbn-xsrf', 'xxx') - .send({}) + .send({ + dataStreams: [testDataStreamName1], + }) .expect(200); expect(body).to.eql({ success: true }); @@ -211,14 +234,14 @@ export default function ({ getService }: FtrProviderContext) { it('can disable lifecycle for a given policy', async () => { const { body } = await supertest - .put(`${API_BASE_PATH}/data_streams/${testDataStreamName}/data_retention`) + .put(`${API_BASE_PATH}/data_streams/data_retention`) .set('kbn-xsrf', 'xxx') - .send({ enabled: false }) + .send({ enabled: false, dataStreams: [testDataStreamName1] }) .expect(200); expect(body).to.eql({ success: true }); - const datastream = await getDatastream(testDataStreamName); + const datastream = await getDatastream(testDataStreamName1); expect(datastream.lifecycle).to.be(undefined); }); }); 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 index 97ceeefbee9bd..0a6101b71ca5c 100644 --- 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 @@ -17,38 +17,42 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const security = getService('security'); const testSubjects = getService('testSubjects'); - const TEST_DS_NAME = 'test-ds-1'; + const TEST_DS_NAME_1 = 'test-ds-1'; + const TEST_DS_NAME_2 = 'test-ds-2'; + const TEST_DATA_STREAM_NAMES = [TEST_DS_NAME_1, TEST_DS_NAME_2]; describe('Data streams tab', function () { before(async () => { await log.debug('Creating required data stream'); try { - await es.indices.putIndexTemplate({ - name: `${TEST_DS_NAME}_index_template`, - index_patterns: [TEST_DS_NAME], - data_stream: {}, - _meta: { - description: `Template for ${TEST_DS_NAME} testing index`, - }, - template: { - settings: { mode: undefined }, - mappings: { - properties: { - '@timestamp': { - type: 'date', + for (const dataStreamName of TEST_DATA_STREAM_NAMES) { + await es.indices.putIndexTemplate({ + name: `${dataStreamName}_index_template`, + index_patterns: [dataStreamName], + data_stream: {}, + _meta: { + description: `Template for ${dataStreamName} testing index`, + }, + template: { + settings: { mode: undefined }, + mappings: { + properties: { + '@timestamp': { + type: 'date', + }, }, }, + lifecycle: { + // @ts-expect-error @elastic/elasticsearch enabled prop is not typed yet + enabled: true, + }, }, - lifecycle: { - // @ts-expect-error @elastic/elasticsearch enabled prop is not typed yet - enabled: true, - }, - }, - }); + }); - await es.indices.createDataStream({ - name: TEST_DS_NAME, - }); + await es.indices.createDataStream({ + name: dataStreamName, + }); + } } catch (e) { log.debug('[Setup error] Error creating test data stream'); throw e; @@ -66,10 +70,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 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`, - }); + for (const dataStreamName of TEST_DATA_STREAM_NAMES) { + await es.indices.deleteDataStream({ name: dataStreamName }); + await es.indices.deleteIndexTemplate({ + name: `${dataStreamName}_index_template`, + }); + } } catch (e) { log.debug('[Teardown error] Error deleting test data stream'); throw e; @@ -78,10 +84,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('shows the details flyout when clicking on a data stream', async () => { // Open details flyout - await pageObjects.indexManagement.clickDataStreamNameLink(TEST_DS_NAME); + await pageObjects.indexManagement.clickDataStreamNameLink(TEST_DS_NAME_1); // Verify url is stateful const url = await browser.getCurrentUrl(); - expect(url).to.contain(`/data_streams/${TEST_DS_NAME}`); + expect(url).to.contain(`/data_streams/${TEST_DS_NAME_1}`); // Assert that flyout is opened expect(await testSubjects.exists('dataStreamDetailPanel')).to.be(true); // Close flyout @@ -91,7 +97,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('shows the correct index mode in the details flyout', function () { it('standard index mode', async () => { // Open details flyout of existing data stream - it has standard index mode - await pageObjects.indexManagement.clickDataStreamNameLink(TEST_DS_NAME); + await pageObjects.indexManagement.clickDataStreamNameLink(TEST_DS_NAME_1); // Check that index mode detail exists and its label is "Standard" expect(await testSubjects.exists('indexModeDetail')).to.be(true); expect(await testSubjects.getVisibleText('indexModeDetail')).to.be('Standard'); @@ -129,44 +135,94 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); - it('allows to update data retention', async () => { - // Open details flyout - await pageObjects.indexManagement.clickDataStreamNameLink(TEST_DS_NAME); - // 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.getElementByIndex(1); - expect(await successToast.getVisibleText()).to.contain('Data retention updated'); - }); + describe('data retention modal', function () { + describe('from details panel', function () { + it('allows to update data retention', async () => { + // Open details flyout + await pageObjects.indexManagement.clickDataStreamNameLink(TEST_DS_NAME_1); + // 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.getElementByIndex(1); + expect(await successToast.getVisibleText()).to.contain('Data retention updated'); + // Clear up toasts for next test + await toasts.dismissAll(); + }); - it('allows to disable data retention', async () => { - // Open details flyout - await pageObjects.indexManagement.clickDataStreamNameLink(TEST_DS_NAME); - // Open the edit retention dialog - await testSubjects.click('manageDataStreamButton'); - await testSubjects.click('editDataRetentionButton'); + it('allows to disable data retention', async () => { + // Open details flyout + await pageObjects.indexManagement.clickDataStreamNameLink(TEST_DS_NAME_1); + // Open the edit retention dialog + await testSubjects.click('manageDataStreamButton'); + await testSubjects.click('editDataRetentionButton'); - // Disable infinite retention - await testSubjects.click('dataRetentionEnabledField > input'); + // Disable infinite retention + await testSubjects.click('dataRetentionEnabledField > input'); - // Submit the form - await testSubjects.click('saveButton'); + // Submit the form + await testSubjects.click('saveButton'); - // Expect to see a success toast - const successToast = await toasts.getElementByIndex(1); - expect(await successToast.getVisibleText()).to.contain('Data retention disabled'); + // Expect to see a success toast + const successToast = await toasts.getElementByIndex(1); + expect(await successToast.getVisibleText()).to.contain('Data retention disabled'); + // Clear up toasts for next test + await toasts.dismissAll(); + }); + }); + + describe('bulk edit modal', function () { + it('allows to update data retention', async () => { + // Select and manage mutliple data streams + await pageObjects.indexManagement.clickBulkEditDataRetention(TEST_DATA_STREAM_NAMES); + + // 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.getElementByIndex(1); + expect(await successToast.getVisibleText()).to.contain( + 'Data retention has been updated for 2 data streams.' + ); + // Clear up toasts for next test + await toasts.dismissAll(); + }); + + it('allows to disable data retention', async () => { + // Select and manage mutliple data streams + await pageObjects.indexManagement.clickBulkEditDataRetention(TEST_DATA_STREAM_NAMES); + + // Disable infinite retention + await testSubjects.click('dataRetentionEnabledField > input'); + + // Submit the form + await testSubjects.click('saveButton'); + + // Expect to see a success toast + const successToast = await toasts.getElementByIndex(1); + expect(await successToast.getVisibleText()).to.contain( + 'Data retention has been updated for 2 data streams.' + ); + // Clear up toasts for next test + await toasts.dismissAll(); + }); + }); }); }); }; 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 e0e2a555540be..207bf2281c080 100644 --- a/x-pack/test/functional/page_objects/index_management_page.ts +++ b/x-pack/test/functional/page_objects/index_management_page.ts @@ -38,6 +38,17 @@ export function IndexManagementPageProvider({ getService }: FtrProviderContext) await policyDetailsLinks[indexOfRow].click(); }, + async clickBulkEditDataRetention(dataStreamNames: string[]): Promise { + for (const dsName of dataStreamNames) { + const checkbox = await testSubjects.find(`checkboxSelectRow-${dsName}`); + if (!(await checkbox.isSelected())) { + await checkbox.click(); + } + } + await testSubjects.click('dataStreamActionsPopoverButton'); + await testSubjects.click('bulkEditDataRetentionButton'); + }, + async clickDataStreamNameLink(name: string): Promise { await find.clickByLinkText(name); }, diff --git a/x-pack/test_serverless/api_integration/test_suites/common/index_management/datastreams.ts b/x-pack/test_serverless/api_integration/test_suites/common/index_management/datastreams.ts index 12151f1b169db..099e1b9b3c1a6 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/index_management/datastreams.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/index_management/datastreams.ts @@ -135,11 +135,12 @@ export default function ({ getService }: FtrProviderContext) { it('updates the data retention of a DS', async () => { const { body, status } = await supertestWithoutAuth - .put(`${API_BASE_PATH}/data_streams/${testDataStreamName}/data_retention`) + .put(`${API_BASE_PATH}/data_streams/data_retention`) .set(internalReqHeader) .set(roleAuthc.apiKeyHeader) .send({ dataRetention: '7d', + dataStreams: [testDataStreamName], }); svlCommonApi.assertResponseStatusCode(200, status, body); @@ -148,10 +149,12 @@ export default function ({ getService }: FtrProviderContext) { it('sets data retention to infinite', async () => { const { body, status } = await supertestWithoutAuth - .put(`${API_BASE_PATH}/data_streams/${testDataStreamName}/data_retention`) + .put(`${API_BASE_PATH}/data_streams/data_retention`) .set(internalReqHeader) .set(roleAuthc.apiKeyHeader) - .send({}); + .send({ + dataStreams: [testDataStreamName], + }); svlCommonApi.assertResponseStatusCode(200, status, body); // Providing an infinite retention might not be allowed for a given project,