[Index management] Data stream edit data retention (#167006)

This commit is contained in:
Ignacio Rivas 2023-09-30 08:43:21 +02:00 committed by GitHub
parent 33183c2d01
commit d35fa69138
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1121 additions and 39 deletions

View file

@ -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`,

View file

@ -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,

View file

@ -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';

View file

@ -34,6 +34,7 @@ export interface DataStreamsTabTestBed extends TestBed<TestSubjects> {
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<DataStream>): DataSt
maxTimeStamp: 420,
privileges: {
delete_index: true,
manage_data_stream_lifecycle: true,
},
hidden: false,
lifecycle: {

View file

@ -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();
});
});
});
});

View file

@ -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';

View file

@ -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' });
});
});

View file

@ -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,
};
};

View file

@ -5,7 +5,11 @@
* 2.0.
*/
export { deserializeDataStream, deserializeDataStreamList } from './data_stream_serialization';
export {
deserializeDataStream,
deserializeDataStreamList,
splitSizeAndUnits,
} from './data_stream_serialization';
export {
deserializeTemplate,

View file

@ -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;
};
}

View file

@ -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<DetailsListProps> = ({ 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 (
<EuiFlexGroup>
@ -93,9 +102,11 @@ export const DataStreamDetailPanel: React.FunctionComponent<Props> = ({
dataStreamName,
onClose,
}) => {
const { error, data: dataStream, isLoading } = useLoadDataStream(dataStreamName);
const [isManagePopOverOpen, setManagePopOver] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isEditingDataRetention, setIsEditingDataRetention] = useState<boolean>(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<Props> = ({
content = <DetailsList details={details} />;
}
const closePopover = () => {
setManagePopOver(false);
};
const button = (
<EuiButton
fill
iconType="arrowDown"
iconSide="right"
data-test-subj="manageDataStreamButton"
onClick={() => setManagePopOver(!isManagePopOverOpen)}
>
<FormattedMessage
id="xpack.idxMgmt.dataStreamsDetailsPanel.manageButtonLabel"
defaultMessage="Manage"
/>
</EuiButton>
);
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: <EuiIcon type="pencil" size="m" />,
onClick: () => {
closePopover();
setIsEditingDataRetention(true);
},
},
]
: []),
...(dataStream?.privileges?.delete_index
? [
{
key: 'deleteDataStream',
name: i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.managePanelDelete', {
defaultMessage: 'Delete',
}),
'data-test-subj': 'deleteDataStreamButton',
icon: <EuiIcon type="trash" size="m" color="danger" />,
onClick: () => {
closePopover();
setIsDeleting(true);
},
},
]
: []),
],
},
];
return (
<>
{isDeleting ? (
{isDeleting && (
<DeleteDataStreamConfirmationModal
onClose={(data) => {
if (data && data.hasDeletedDataStreams) {
@ -288,7 +364,21 @@ export const DataStreamDetailPanel: React.FunctionComponent<Props> = ({
}}
dataStreams={[dataStreamName]}
/>
) : null}
)}
{isEditingDataRetention && (
<EditDataRetentionModal
onClose={(data) => {
if (data && data?.hasUpdatedDataRetention) {
onClose(true);
} else {
setIsEditingDataRetention(false);
}
}}
dataStreamName={dataStreamName}
dataRetention={dataStream?.lifecycle?.data_retention as string}
/>
)}
<EuiFlyout
onClose={() => onClose()}
@ -324,20 +414,19 @@ export const DataStreamDetailPanel: React.FunctionComponent<Props> = ({
</EuiButtonEmpty>
</EuiFlexItem>
{!isLoading && !error && dataStream?.privileges.delete_index ? (
{!isLoading && !error && panels[0].items?.length && (
<EuiFlexItem grow={false}>
<EuiButton
color="danger"
iconType="trash"
onClick={() => setIsDeleting(true)}
data-test-subj="deleteDataStreamButton"
<EuiPopover
button={button}
isOpen={isManagePopOverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
{i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.deleteButtonLabel', {
defaultMessage: 'Delete data stream',
})}
</EuiButton>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
</EuiFlexItem>
) : null}
)}
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>

View file

@ -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<Props> = ({
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 (
<EuiModal onClose={() => onClose()} data-test-subj="editDataRetentionModal">
<Form form={form} data-test-subj="editDataRetentionForm">
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.modalTitleText"
defaultMessage="Edit data retention"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<UseField
path="dataRetention"
component={NumericField}
labelAppend={
<EuiText size="xs">
<EuiLink href={documentationService.getUpdateExistingDS()} target="_blank" external>
{i18n.translate(
'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.learnMoreLinkText',
{
defaultMessage: 'How does it work?',
}
)}
</EuiLink>
</EuiText>
}
componentProps={{
fullWidth: false,
euiFieldProps: {
disabled: formData.infiniteRetentionPeriod,
'data-test-subj': `dataRetentionValue`,
min: 1,
append: (
<UnitField
path="timeUnit"
options={timeUnits}
disabled={formData.infiniteRetentionPeriod}
euiFieldProps={{
'data-test-subj': 'timeUnit',
'aria-label': i18n.translate(
'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.unitsAriaLabel',
{
defaultMessage: 'Time unit',
}
),
}}
/>
),
},
}}
/>
<UseField
path="infiniteRetentionPeriod"
component={ToggleField}
data-test-subj="infiniteRetentionPeriod"
/>
<EuiSpacer />
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty data-test-subj="cancelButton" onClick={() => onClose()}>
<FormattedMessage
id="xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
fill
type="submit"
isLoading={false}
disabled={(form.isSubmitted && form.isValid === false) || !isDirty}
data-test-subj="saveButton"
onClick={onSubmitForm}
>
<FormattedMessage
id="xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiModalFooter>
</Form>
</EuiModal>
);
};

View file

@ -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';

View file

@ -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<string, any>;
options: Array<{
value: string;
text: string;
}>;
}
export const UnitField: FunctionComponent<Props> = ({ path, disabled, options, euiFieldProps }) => {
const [open, setOpen] = useState(false);
return (
<UseField key={path} path={path}>
{(field) => {
const onSelect = (option: string) => {
field.setValue(option);
setOpen(false);
};
return (
<EuiPopover
button={
<EuiButtonEmpty
size="xs"
color="text"
iconSide="right"
iconType="arrowDown"
onClick={() => setOpen((isOpen) => !isOpen)}
data-test-subj="show-filters-button"
disabled={disabled}
>
{options.find((timeUnitOption) => timeUnitOption.value === field.value)?.text ??
`${field.value}`}
</EuiButtonEmpty>
}
ownFocus
panelPaddingSize="none"
isOpen={open}
closePopover={() => setOpen(false)}
{...euiFieldProps}
>
{options.map((item) => (
<EuiFilterSelectItem
key={item.value}
checked={field.value === item.value ? 'on' : undefined}
onClick={() => onSelect(item.value)}
data-test-subj={`filter-option-${item.value}`}
>
{item.text}
</EuiFilterSelectItem>
))}
</EuiPopover>
);
}}
</UseField>
);
};

View file

@ -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<any>(`${API_BASE_PATH}/indices`);
return response.data ? response.data : response;

View file

@ -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';
}

View file

@ -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';

View file

@ -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);
});
});
});

View file

@ -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);
}

View file

@ -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'],
},
],
},

View file

@ -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<typeof paramsSchema>;
const { dataRetention } = request.body as TypeOf<typeof bodySchema>;
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 });
}
}
);
}

View file

@ -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é",

View file

@ -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": "ヘルス",

View file

@ -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": "运行状况",

View file

@ -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';

View file

@ -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');
});
});
};

View file

@ -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'));
});
};

View file

@ -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'));
});
};

View file

@ -34,6 +34,11 @@ export function IndexManagementPageProvider({ getService }: FtrProviderContext)
await policyDetailsLinks[indexOfRow].click();
},
async clickDataStreamAt(indexOfRow: number): Promise<void> {
const dataStreamLinks = await testSubjects.findAll('nameLink');
await dataStreamLinks[indexOfRow].click();
},
async clickDeleteEnrichPolicyAt(indexOfRow: number): Promise<void> {
const deleteButons = await testSubjects.findAll('deletePolicyButton');
await deleteButons[indexOfRow].click();

View file

@ -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');
});
});
};