[Index Management] Add data retention to component template UI (#170837)

This commit is contained in:
Ignacio Rivas 2023-11-10 18:59:49 +01:00 committed by GitHub
parent 9b29b1898e
commit 02ccb7788a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 267 additions and 8 deletions

View file

@ -8,15 +8,18 @@
import { IndexSettings } from './indices';
import { Aliases } from './aliases';
import { Mappings } from './mappings';
import { DataStream, DataRetention } from '.';
export interface ComponentTemplateSerialized {
template: {
settings?: IndexSettings;
aliases?: Aliases;
mappings?: Mappings;
lifecycle?: DataStream['lifecycle'];
};
version?: number;
_meta?: { [key: string]: any };
lifecycle?: DataRetention;
}
export interface ComponentTemplateDeserialized extends ComponentTemplateSerialized {

View file

@ -12,6 +12,7 @@ import { breadcrumbService, IndexManagementBreadcrumb } from '../../../../servic
import { setupEnvironment } from './helpers';
import { API_BASE_PATH } from './helpers/constants';
import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers';
import { serializeAsESLifecycle } from '../../../../../../common/lib/data_stream_serialization';
jest.mock('@kbn/kibana-react-plugin/public', () => {
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
@ -98,6 +99,19 @@ describe('<ComponentTemplateCreate />', () => {
expect(exists('metaEditor')).toBe(true);
});
test('should toggle the data retention field', async () => {
const { exists, component, form } = testBed;
expect(exists('valueDataRetentionField')).toBe(false);
await act(async () => {
form.toggleEuiSwitch('dataRetentionToggle.input');
});
component.update();
expect(exists('valueDataRetentionField')).toBe(true);
});
describe('Validation', () => {
test('should require a name', async () => {
const { form, actions, component, find } = testBed;
@ -120,6 +134,11 @@ describe('<ComponentTemplateCreate />', () => {
const COMPONENT_TEMPLATE_NAME = 'comp-1';
const SETTINGS = { number_of_shards: 1 };
const ALIASES = { my_alias: {} };
const LIFECYCLE = {
enabled: true,
value: 2,
unit: 'd',
};
const BOOLEAN_MAPPING_FIELD = {
name: 'boolean_datatype',
@ -136,7 +155,10 @@ describe('<ComponentTemplateCreate />', () => {
component.update();
// Complete step 1 (logistics)
await actions.completeStepLogistics({ name: COMPONENT_TEMPLATE_NAME });
await actions.completeStepLogistics({
name: COMPONENT_TEMPLATE_NAME,
lifecycle: LIFECYCLE,
});
// Complete step 2 (index settings)
await actions.completeStepSettings(SETTINGS);
@ -199,6 +221,7 @@ describe('<ComponentTemplateCreate />', () => {
},
},
aliases: ALIASES,
lifecycle: serializeAsESLifecycle(LIFECYCLE),
},
_kbnMeta: { usedBy: [], isManaged: false },
}),

View file

@ -19,6 +19,7 @@ const COMPONENT_TEMPLATE: ComponentTemplateDeserialized = {
mappings: { properties: { ip_address: { type: 'ip' } } },
aliases: { mydata: {} },
settings: { number_of_shards: 1 },
lifecycle: { enabled: true, data_retention: '4d' },
},
version: 1,
_meta: { description: 'component template test' },
@ -72,6 +73,7 @@ describe('<ComponentTemplateDetails />', () => {
expect(exists('summaryTabContent.usedByTitle')).toBe(true);
expect(exists('summaryTabContent.versionTitle')).toBe(true);
expect(exists('summaryTabContent.metaTitle')).toBe(true);
expect(exists('summaryTabContent.dataRetentionTitle')).toBe(true);
// [Settings tab] Navigate to tab and verify content
act(() => {

View file

@ -78,6 +78,7 @@ export type ComponentTemplateDetailsTestSubjects =
| 'summaryTabContent.usedByTitle'
| 'summaryTabContent.versionTitle'
| 'summaryTabContent.metaTitle'
| 'summaryTabContent.dataRetentionTitle'
| 'notInUseCallout'
| 'aliasesTabContent'
| 'noAliasesCallout'

View file

@ -8,6 +8,7 @@
import { act } from 'react-dom/test-utils';
import { TestBed } from '@kbn/test-jest-helpers';
import { DataRetention } from '../../../../../../../common';
interface MappingField {
name: string;
@ -52,11 +53,28 @@ export const getFormActions = (testBed: TestBed) => {
.simulate('click');
};
const completeStepLogistics = async ({ name }: { name: string }) => {
const completeStepLogistics = async ({
name,
lifecycle,
}: {
name: string;
lifecycle: DataRetention;
}) => {
const { form, component } = testBed;
// Add name field
form.setInputValue('nameField.input', name);
if (lifecycle && lifecycle.enabled) {
act(() => {
form.toggleEuiSwitch('dataRetentionToggle.input');
});
component.update();
act(() => {
form.setInputValue('valueDataRetentionField', String(lifecycle.value));
});
}
await act(async () => {
clickNextButton();
});
@ -164,6 +182,8 @@ export type ComponentTemplateFormTestSubjects =
| 'stepReview.content'
| 'stepReview.summaryTab'
| 'stepReview.requestTab'
| 'valueDataRetentionField'
| 'dataRetentionToggle.input'
| 'versionField'
| 'aliasesEditor'
| 'mappingsEditor'

View file

@ -19,6 +19,7 @@ import {
EuiLink,
} from '@elastic/eui';
import { getLifecycleValue } from '../../../lib/data_streams';
import { ComponentTemplateDeserialized } from '../shared_imports';
import { useComponentTemplatesContext } from '../component_templates_context';
@ -27,13 +28,15 @@ interface Props {
showCallToAction?: boolean;
}
const INFINITE_AS_ICON = true;
export const TabSummary: React.FunctionComponent<Props> = ({
componentTemplateDetails,
showCallToAction,
}) => {
const { getUrlForApp } = useComponentTemplatesContext();
const { version, _meta, _kbnMeta } = componentTemplateDetails;
const { version, _meta, _kbnMeta, template } = componentTemplateDetails;
const { usedBy } = _kbnMeta;
const templateIsInUse = usedBy.length > 0;
@ -118,6 +121,20 @@ export const TabSummary: React.FunctionComponent<Props> = ({
</>
)}
{template.lifecycle && (
<>
<EuiDescriptionListTitle data-test-subj="dataRetentionTitle">
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.summaryTab.dataRetentionDescriptionListTitle"
defaultMessage="Data retention"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getLifecycleValue(template.lifecycle, INFINITE_AS_ICON)}
</EuiDescriptionListDescription>
</>
)}
{/* Version (optional) */}
{typeof version !== 'undefined' && (
<>

View file

@ -19,6 +19,10 @@ import {
StepMappingsContainer,
StepAliasesContainer,
} from '../../shared_imports';
import {
serializeAsESLifecycle,
deserializeESLifecycle,
} from '../../../../../../common/lib/data_stream_serialization';
import { useComponentTemplatesContext } from '../../component_templates_context';
import { StepLogisticsContainer, StepReviewContainer } from './steps';
@ -96,14 +100,17 @@ export const ComponentTemplateForm = ({
onStepChange,
}: Props) => {
const {
template: { settings, mappings, aliases },
template: { settings, mappings, aliases, lifecycle },
...logistics
} = defaultValue;
const { documentation } = useComponentTemplatesContext();
const wizardDefaultValue: WizardContent = {
logistics,
logistics: {
...logistics,
...(lifecycle ? { lifecycle: deserializeESLifecycle(lifecycle) } : {}),
},
settings,
mappings,
aliases,
@ -162,6 +169,10 @@ export const ComponentTemplateForm = ({
delete outputTemplate.template.aliases;
}
if (outputTemplate.lifecycle) {
delete outputTemplate.lifecycle;
}
return outputTemplate;
};
@ -177,9 +188,14 @@ export const ComponentTemplateForm = ({
settings: wizardData.settings,
mappings: wizardData.mappings,
aliases: wizardData.aliases,
lifecycle: wizardData.logistics.lifecycle
? serializeAsESLifecycle(wizardData.logistics.lifecycle)
: undefined,
},
};
return cleanupComponentTemplateObject(outputComponentTemplate);
return cleanupComponentTemplateObject(
outputComponentTemplate as ComponentTemplateDeserialized
);
},
[]
);

View file

@ -24,8 +24,12 @@ import {
getFormRow,
Field,
Forms,
NumericField,
JsonEditorField,
useFormData,
} from '../../../shared_imports';
import { DataRetention } from '../../../../../../../common';
import { UnitField, timeUnits } from '../../../../shared';
import { useComponentTemplatesContext } from '../../../component_templates_context';
import { logisticsFormSchema } from './step_logistics_schema';
@ -48,6 +52,13 @@ export const StepLogistics: React.FunctionComponent<Props> = React.memo(
const { isValid: isFormValid, submit, getFormData, subscribe } = form;
const [{ lifecycle }] = useFormData<{
lifecycle: DataRetention;
}>({
form,
watch: ['lifecycle.enabled', 'lifecycle.infiniteDataRetention'],
});
const { documentation } = useComponentTemplatesContext();
const [isMetaVisible, setIsMetaVisible] = useState<boolean>(
@ -134,6 +145,64 @@ export const StepLogistics: React.FunctionComponent<Props> = React.memo(
/>
</FormRow>
{/* Data retention field */}
<FormRow
title={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.dataRetentionTitle"
defaultMessage="Data retention"
/>
}
description={
<>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.dataRetentionDescription"
defaultMessage="Data will be kept at least this long before being automatically deleted."
/>
<EuiSpacer size="m" />
<UseField
path="lifecycle.enabled"
componentProps={{ 'data-test-subj': 'dataRetentionToggle' }}
/>
</>
}
>
{lifecycle?.enabled && (
<UseField
path="lifecycle.value"
component={NumericField}
labelAppend={
<UseField
path="lifecycle.infiniteDataRetention"
data-test-subj="infiniteDataRetentionToggle"
componentProps={{
euiFieldProps: {
compressed: true,
},
}}
/>
}
componentProps={{
euiFieldProps: {
disabled: lifecycle?.infiniteDataRetention,
'data-test-subj': 'valueDataRetentionField',
min: 1,
append: (
<UnitField
path="lifecycle.unit"
options={timeUnits}
disabled={lifecycle?.infiniteDataRetention}
euiFieldProps={{
'data-test-subj': 'unitDataRetentionField',
}}
/>
),
},
}}
/>
)}
</FormRow>
{/* version field */}
<FormRow
title={

View file

@ -58,6 +58,89 @@ export const logisticsFormSchema: FormSchema = {
},
],
},
'lifecycle.enabled': {
type: FIELD_TYPES.TOGGLE,
label: i18n.translate(
'xpack.idxMgmt.componentTemplateForm.stepLogistics.enableDataRetentionLabel',
{
defaultMessage: 'Enable data retention',
}
),
defaultValue: false,
},
'lifecycle.infiniteDataRetention': {
type: FIELD_TYPES.TOGGLE,
label: i18n.translate(
'xpack.idxMgmt.componentTemplateForm.stepLogistics.infiniteDataRetentionLabel',
{
defaultMessage: 'Keep data indefinitely',
}
),
defaultValue: false,
},
'lifecycle.value': {
type: FIELD_TYPES.TEXT,
label: i18n.translate(
'xpack.idxMgmt.componentTemplateForm.stepLogistics.fieldDataRetentionValueLabel',
{
defaultMessage: 'Data Retention',
}
),
formatters: [toInt],
validations: [
{
validator: ({ value, formData }) => {
// If infiniteRetentionPeriod is set, we dont need to validate the data retention field
if (formData['lifecycle.infiniteDataRetention']) {
return undefined;
}
if (!value) {
return {
message: i18n.translate(
'xpack.idxMgmt.dataStreamsDetailsPanel.stepLogistics.dataRetentionFieldRequiredError',
{
defaultMessage: 'A data retention value is required.',
}
),
};
}
if (value <= 0) {
return {
message: i18n.translate(
'xpack.idxMgmt.dataStreamsDetailsPanel.stepLogistics.dataRetentionFieldNonNegativeError',
{
defaultMessage: `A positive value is required.`,
}
),
};
}
if (value % 1 !== 0) {
return {
message: i18n.translate(
'xpack.idxMgmt.dataStreamsDetailsPanel.stepLogistics.dataRetentionFieldDecimalError',
{
defaultMessage: `The value should be an integer number.`,
}
),
};
}
},
},
],
},
'lifecycle.unit': {
type: FIELD_TYPES.TEXT,
label: i18n.translate(
'xpack.idxMgmt.componentTemplateForm.stepLogistics.fieldDataRetentionUnitLabel',
{
defaultMessage: 'Time unit',
}
),
defaultValue: 'd',
},
version: {
type: FIELD_TYPES.NUMBER,
label: i18n.translate('xpack.idxMgmt.componentTemplateForm.stepLogistics.versionFieldLabel', {

View file

@ -28,7 +28,9 @@ import {
serializeComponentTemplate,
} from '../../../shared_imports';
import { MANAGED_BY_FLEET } from '../../../constants';
import { getLifecycleValue } from '../../../../../lib/data_streams';
const INFINITE_AS_ICON = true;
const { stripEmptyFields } = serializers;
const getDescriptionText = (data: any) => {
@ -123,6 +125,17 @@ export const StepReview: React.FunctionComponent<Props> = React.memo(
<EuiDescriptionListDescription>
{getDescriptionText(serializedTemplate?.aliases)}
</EuiDescriptionListDescription>
{/* Data retention */}
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepReview.summaryTab.dataRetentionLabel"
defaultMessage="Data retention"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getLifecycleValue(serializedTemplate?.lifecycle, INFINITE_AS_ICON)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
{isFleetDatastreamsVisible && dataStreams && (

View file

@ -41,12 +41,14 @@ export {
useForm,
Form,
getUseField,
useFormData,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
export {
getFormRow,
Field,
JsonEditorField,
NumericField,
} from '@kbn/es-ui-shared-plugin/static/forms/components';
export { isJSON } from '@kbn/es-ui-shared-plugin/static/validators/string';

View file

@ -33,9 +33,9 @@ export function registerGetAllRoute({ router, lib: { handleEsError } }: RouteDep
const { index_templates: indexTemplates } =
await client.asCurrentUser.indices.getIndexTemplate();
const body = componentTemplates.map((componentTemplate: ComponentTemplateFromEs) => {
const body = componentTemplates.map((componentTemplate) => {
const deserializedComponentTemplateListItem = deserializeComponentTemplateList(
componentTemplate,
componentTemplate as ComponentTemplateFromEs,
// @ts-expect-error TemplateSerialized.index_patterns not compatible with IndicesIndexTemplate.index_patterns
indexTemplates
);

View file

@ -13,6 +13,12 @@ export const componentTemplateSchema = schema.object({
settings: schema.maybe(schema.object({}, { unknowns: 'allow' })),
aliases: schema.maybe(schema.object({}, { unknowns: 'allow' })),
mappings: schema.maybe(schema.object({}, { unknowns: 'allow' })),
lifecycle: schema.maybe(
schema.object({
enabled: schema.boolean(),
data_retention: schema.maybe(schema.string()),
})
),
}),
version: schema.maybe(schema.number()),
_meta: schema.maybe(schema.object({}, { unknowns: 'allow' })),

View file

@ -147,6 +147,10 @@ export default function ({ getService }: FtrProviderContext) {
},
},
},
lifecycle: {
enabled: true,
data_retention: '2d',
},
},
_meta: {
description: 'set number of shards to one',