[Mappings editor] Add support for the _size parameter (#119365)

This commit is contained in:
Sébastien Loix 2021-12-07 17:15:23 +00:00 committed by GitHub
parent 673039d89d
commit d0f7d7c21d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 712 additions and 181 deletions

View file

@ -290,7 +290,14 @@ readonly links: {
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly plugins: {
azureRepo: string;
gcsRepo: string;
hdfsRepo: string;
s3Repo: string;
snapshotRestoreRepos: string;
mapperSize: string;
};
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{

View file

@ -486,6 +486,7 @@ export class DocLinksService {
hdfsRepo: `${PLUGIN_DOCS}repository-hdfs.html`,
s3Repo: `${PLUGIN_DOCS}repository-s3.html`,
snapshotRestoreRepos: `${PLUGIN_DOCS}repository.html`,
mapperSize: `${PLUGIN_DOCS}mapper-size-usage.html`,
},
snapshotRestore: {
guide: `${ELASTICSEARCH_DOCS}snapshot-restore.html`,
@ -874,7 +875,14 @@ export interface DocLinksStart {
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly plugins: {
azureRepo: string;
gcsRepo: string;
hdfsRepo: string;
s3Repo: string;
snapshotRestoreRepos: string;
mapperSize: string;
};
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{

View file

@ -773,7 +773,14 @@ export interface DocLinksStart {
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly plugins: {
azureRepo: string;
gcsRepo: string;
hdfsRepo: string;
s3Repo: string;
snapshotRestoreRepos: string;
mapperSize: string;
};
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{

View file

@ -140,6 +140,17 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
]);
};
const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: any) => {
const status = error ? error.status || 400 : 200;
const body = error ? error.body : response;
server.respondWith('GET', `${API_BASE_PATH}/nodes/plugins`, [
status,
{ 'Content-Type': 'application/json' },
JSON.stringify(body),
]);
};
return {
setLoadTemplatesResponse,
setLoadIndicesResponse,
@ -154,6 +165,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
setUpdateIndexSettingsResponse,
setSimulateTemplateResponse,
setLoadComponentTemplatesResponse,
setLoadNodesPluginsResponse,
};
};

View file

@ -82,6 +82,7 @@ describe('<TemplateCreate />', () => {
jest.useFakeTimers();
httpRequestsMockHelpers.setLoadComponentTemplatesResponse(componentTemplates);
httpRequestsMockHelpers.setLoadNodesPluginsResponse([]);
// disable all react-beautiful-dnd development warnings
(window as any)['__react-beautiful-dnd-disable-dev-warnings'] = true;
@ -296,7 +297,7 @@ describe('<TemplateCreate />', () => {
});
describe('mappings (step 4)', () => {
beforeEach(async () => {
const navigateToMappingsStep = async () => {
const { actions } = testBed;
// Logistics
await actions.completeStepOne({ name: TEMPLATE_NAME, indexPatterns: ['index1'] });
@ -304,6 +305,10 @@ describe('<TemplateCreate />', () => {
await actions.completeStepTwo();
// Index settings
await actions.completeStepThree('{}');
};
beforeEach(async () => {
await navigateToMappingsStep();
});
it('should set the correct page title', () => {
@ -337,6 +342,43 @@ describe('<TemplateCreate />', () => {
expect(find('fieldsListItem').length).toBe(1);
});
describe('plugin parameters', () => {
const selectMappingsEditorTab = async (
tab: 'fields' | 'runtimeFields' | 'templates' | 'advanced'
) => {
const tabIndex = ['fields', 'runtimeFields', 'templates', 'advanced'].indexOf(tab);
const tabElement = testBed.find('mappingsEditor.formTab').at(tabIndex);
await act(async () => {
tabElement.simulate('click');
});
testBed.component.update();
};
test('should not render the _size parameter if the mapper size plugin is not installed', async () => {
const { exists } = testBed;
// Navigate to the advanced configuration
await selectMappingsEditorTab('advanced');
expect(exists('mappingsEditor.advancedConfiguration.sizeEnabledToggle')).toBe(false);
});
test('should render the _size parameter if the mapper size plugin is installed', async () => {
httpRequestsMockHelpers.setLoadNodesPluginsResponse(['mapper-size']);
await act(async () => {
testBed = await setup();
});
testBed.component.update();
await navigateToMappingsStep();
await selectMappingsEditorTab('advanced');
expect(testBed.exists('mappingsEditor.advancedConfiguration.sizeEnabledToggle')).toBe(
true
);
});
});
});
describe('aliases (step 5)', () => {

View file

@ -339,4 +339,6 @@ export type TestSubjects =
| 'versionField'
| 'aliasesEditor'
| 'settingsEditor'
| 'versionField.input';
| 'versionField.input'
| 'mappingsEditor.formTab'
| 'mappingsEditor.advancedConfiguration.sizeEnabledToggle';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { ComponentType, MemoExoticComponent } from 'react';
import SemVer from 'semver/classes/semver';
/* eslint-disable-next-line @kbn/eslint/no-restricted-paths */
@ -18,6 +18,7 @@ import {
import { MAJOR_VERSION } from '../../../../../../../common';
import { MappingsEditorProvider } from '../../../mappings_editor_context';
import { createKibanaReactContext } from '../../../shared_imports';
import { Props as MappingsEditorProps } from '../../../mappings_editor';
export const kibanaVersion = new SemVer(MAJOR_VERSION);
@ -82,17 +83,21 @@ const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
},
});
const defaultProps = {
const defaultProps: MappingsEditorProps = {
docLinks: docLinksServiceMock.createStartContract(),
onChange: () => undefined,
esNodesPlugins: [],
};
export const WithAppDependencies = (Comp: any) => (props: any) =>
(
<KibanaReactContextProvider>
<MappingsEditorProvider>
<GlobalFlyoutProvider>
<Comp {...defaultProps} {...props} />
</GlobalFlyoutProvider>
</MappingsEditorProvider>
</KibanaReactContextProvider>
);
export const WithAppDependencies =
(Comp: MemoExoticComponent<ComponentType<MappingsEditorProps>>) =>
(props: Partial<MappingsEditorProps>) =>
(
<KibanaReactContextProvider>
<MappingsEditorProvider>
<GlobalFlyoutProvider>
<Comp {...defaultProps} {...props} />
</GlobalFlyoutProvider>
</MappingsEditorProvider>
</KibanaReactContextProvider>
);

View file

@ -10,15 +10,19 @@ import { EuiSpacer } from '@elastic/eui';
import { useForm, Form } from '../../shared_imports';
import { GenericObject, MappingsConfiguration } from '../../types';
import { MapperSizePluginId } from '../../constants';
import { useDispatch } from '../../mappings_state_context';
import { DynamicMappingSection } from './dynamic_mapping_section';
import { SourceFieldSection } from './source_field_section';
import { MetaFieldSection } from './meta_field_section';
import { RoutingSection } from './routing_section';
import { MapperSizePluginSection } from './mapper_size_plugin_section';
import { configurationFormSchema } from './configuration_form_schema';
interface Props {
value?: MappingsConfiguration;
/** List of plugins installed in the cluster nodes */
esNodesPlugins: string[];
}
const formSerializer = (formData: GenericObject) => {
@ -35,6 +39,7 @@ const formSerializer = (formData: GenericObject) => {
sourceField,
metaField,
_routing,
_size,
} = formData;
const dynamic = dynamicMappingsEnabled ? true : throwErrorsForUnmappedFields ? 'strict' : false;
@ -47,6 +52,7 @@ const formSerializer = (formData: GenericObject) => {
_source: sourceField,
_meta: metaField,
_routing,
_size,
};
return serialized;
@ -67,6 +73,8 @@ const formDeserializer = (formData: GenericObject) => {
},
_meta,
_routing,
// For the Mapper Size plugin
_size,
} = formData;
return {
@ -84,10 +92,11 @@ const formDeserializer = (formData: GenericObject) => {
},
metaField: _meta ?? {},
_routing,
_size,
};
};
export const ConfigurationForm = React.memo(({ value }: Props) => {
export const ConfigurationForm = React.memo(({ value, esNodesPlugins }: Props) => {
const isMounted = useRef(false);
const { form } = useForm({
@ -100,6 +109,9 @@ export const ConfigurationForm = React.memo(({ value }: Props) => {
const dispatch = useDispatch();
const { subscribe, submit, reset, getFormData } = form;
const isMapperSizeSectionVisible =
value?._size !== undefined || esNodesPlugins.includes(MapperSizePluginId);
useEffect(() => {
const subscription = subscribe(({ data, isValid, validate }) => {
dispatch({
@ -150,6 +162,7 @@ export const ConfigurationForm = React.memo(({ value }: Props) => {
<SourceFieldSection />
<EuiSpacer size="xl" />
<RoutingSection />
{isMapperSizeSectionVisible && <MapperSizePluginSection />}
</Form>
);
});

View file

@ -192,4 +192,12 @@ export const configurationFormSchema: FormSchema = {
defaultValue: false,
},
},
_size: {
enabled: {
label: i18n.translate('xpack.idxMgmt.mappingsEditor.configuration.sizeLabel', {
defaultMessage: 'Index the _source field size in bytes',
}),
defaultValue: false,
},
},
};

View file

@ -0,0 +1,47 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink, EuiCode } from '@elastic/eui';
import { documentationService } from '../../../../services/documentation';
import { UseField, FormRow, ToggleField } from '../../shared_imports';
export const MapperSizePluginSection = () => {
return (
<FormRow
title={i18n.translate('xpack.idxMgmt.mappingsEditor.sizeTitle', {
defaultMessage: '_size',
})}
description={
<FormattedMessage
id="xpack.idxMgmt.mappingsEditor.sizeDescription"
defaultMessage="The Mapper Size plugin can index the size of the original {_source} field. {docsLink}"
values={{
docsLink: (
<EuiLink href={documentationService.docLinks.plugins.mapperSize} target="_blank">
{i18n.translate('xpack.idxMgmt.mappingsEditor.sizeDocumentionLink', {
defaultMessage: 'Learn more.',
})}
</EuiLink>
),
_source: <EuiCode>_source</EuiCode>,
}}
/>
}
>
<UseField
path="_size.enabled"
component={ToggleField}
componentProps={{ 'data-test-subj': 'sizeEnabledToggle' }}
/>
</FormRow>
);
};

View file

@ -13,10 +13,12 @@ import { LoadMappingsProvider } from './load_mappings_provider';
interface Props {
onJson(json: { [key: string]: any }): void;
/** List of plugins installed in the cluster nodes */
esNodesPlugins: string[];
}
export const LoadMappingsFromJsonButton = ({ onJson }: Props) => (
<LoadMappingsProvider onJson={onJson}>
export const LoadMappingsFromJsonButton = ({ onJson, esNodesPlugins }: Props) => (
<LoadMappingsProvider onJson={onJson} esNodesPlugins={esNodesPlugins}>
{(openModal) => (
<EuiButtonEmpty onClick={openModal} size="s">
{i18n.translate('xpack.idxMgmt.mappingsEditor.loadFromJsonButtonLabel', {

View file

@ -22,7 +22,7 @@ import { registerTestBed, TestBed } from '@kbn/test/jest';
import { LoadMappingsProvider } from './load_mappings_provider';
const ComponentToTest = ({ onJson }: { onJson: () => void }) => (
<LoadMappingsProvider onJson={onJson}>
<LoadMappingsProvider onJson={onJson} esNodesPlugins={[]}>
{(openModal) => (
<button onClick={openModal} data-test-subj="load-json-button">
Load JSON

View file

@ -19,6 +19,8 @@ type OpenJsonModalFunc = () => void;
interface Props {
onJson(json: { [key: string]: any }): void;
/** List of plugins installed in the cluster nodes */
esNodesPlugins: string[];
children: (openModal: OpenJsonModalFunc) => React.ReactNode;
}
@ -120,7 +122,7 @@ const getErrorMessage = (error: MappingsValidationError) => {
}
};
export const LoadMappingsProvider = ({ onJson, children }: Props) => {
export const LoadMappingsProvider = ({ onJson, esNodesPlugins, children }: Props) => {
const [state, setState] = useState<State>({ isModalOpen: false });
const [totalErrorsToDisplay, setTotalErrorsToDisplay] = useState<number>(MAX_ERRORS_TO_DISPLAY);
const jsonContent = useRef<Parameters<OnJsonEditorUpdateHandler>['0'] | undefined>();
@ -153,7 +155,7 @@ export const LoadMappingsProvider = ({ onJson, children }: Props) => {
if (isValidJson) {
// Parse and validate the JSON to make sure it won't break the UI
const unparsed = jsonContent.current.data.format();
const { value: parsed, errors } = validateMappings(unparsed);
const { value: parsed, errors } = validateMappings(unparsed, esNodesPlugins);
if (errors) {
setState({ isModalOpen: true, json: { unparsed, parsed }, errors });

View file

@ -14,3 +14,5 @@ export * from './data_types_definition';
export * from './parameters_definition';
export * from './mappings_editor';
export const MapperSizePluginId = 'mapper-size';

View file

@ -101,7 +101,7 @@ describe('extractMappingsDefinition', () => {
expect(extractMappingsDefinition(mappings)).toBe(mappings.myType);
});
test('should detect that the mappings has one type at root level', () => {
test('should detect that the mappings has no type and is defined at root level', () => {
const mappings = {
_source: {
excludes: [],
@ -122,6 +122,10 @@ describe('extractMappingsDefinition', () => {
type: 'keyword',
},
},
// Mapper-size plugin parameter
_size: {
enabled: true,
},
};
expect(extractMappingsDefinition(mappings)).toBe(mappings);

View file

@ -8,10 +8,15 @@
import { isPlainObject } from 'lodash';
import { GenericObject } from '../types';
import { validateMappingsConfiguration, VALID_MAPPINGS_PARAMETERS } from './mappings_validator';
import {
validateMappingsConfiguration,
mappingsConfigurationSchemaKeys,
} from './mappings_validator';
const isMappingDefinition = (obj: GenericObject): boolean => {
const areAllKeysValid = Object.keys(obj).every((key) => VALID_MAPPINGS_PARAMETERS.includes(key));
const areAllKeysValid = Object.keys(obj).every((key) =>
mappingsConfigurationSchemaKeys.includes(key)
);
if (!areAllKeysValid) {
return false;

View file

@ -7,6 +7,7 @@
import { isPlainObject } from 'lodash';
import { MapperSizePluginId } from '../constants';
import { validateMappings, validateProperties } from './mappings_validator';
describe('Mappings configuration validator', () => {
@ -32,9 +33,13 @@ describe('Mappings configuration validator', () => {
required: false,
},
dynamic: true,
// Mapper-size plugin
_size: {
enabled: true,
},
};
const { errors } = validateMappings(mappings);
const { errors } = validateMappings(mappings, [MapperSizePluginId]);
expect(errors).toBe(undefined);
});
@ -72,9 +77,14 @@ describe('Mappings configuration validator', () => {
excludes: ['abc'],
},
properties: 'abc',
// We add the Mapper Size plugin "_size" param but don't provide
// the plugin when calling validateMappings(..., <listOfNodesPlugins>)
_size: {
enabled: true,
},
};
const { value, errors } = validateMappings(mappings);
const { value, errors } = validateMappings(mappings, []);
expect(value).toEqual({
dynamic: true,
@ -87,6 +97,7 @@ describe('Mappings configuration validator', () => {
{ code: 'ERR_CONFIG', configName: '_source' },
{ code: 'ERR_CONFIG', configName: 'dynamic_date_formats' },
{ code: 'ERR_CONFIG', configName: 'numeric_detection' },
{ code: 'ERR_CONFIG', configName: '_size' },
]);
});
});

View file

@ -12,7 +12,7 @@ import { toArray } from 'fp-ts/lib/Set';
import { isLeft, isRight } from 'fp-ts/lib/Either';
import { errorReporter } from './error_reporter';
import { ALL_DATA_TYPES, PARAMETERS_DEFINITION } from '../constants';
import { ALL_DATA_TYPES, PARAMETERS_DEFINITION, MapperSizePluginId } from '../constants';
import { FieldMeta } from '../types';
import { getFieldMeta } from './utils';
@ -52,6 +52,11 @@ interface GenericObject {
[key: string]: any;
}
interface ValidationResponse {
value: GenericObject;
errors: MappingsValidationError[];
}
const validateFieldType = (type: any): boolean => {
if (typeof type !== 'string') {
return false;
@ -185,13 +190,13 @@ const parseFields = (
*
* @param properties A mappings "properties" object
*/
export const validateProperties = (properties = {}): PropertiesValidatorResponse => {
export const validateProperties = (properties: unknown = {}): PropertiesValidatorResponse => {
// Sanitize the input to make sure we are working with an object
if (!isPlainObject(properties)) {
return { value: {}, errors: [] };
}
return parseFields(properties);
return parseFields(properties as GenericObject);
};
/**
@ -200,6 +205,8 @@ export const validateProperties = (properties = {}): PropertiesValidatorResponse
*/
export const mappingsConfigurationSchema = t.exact(
t.partial({
properties: t.UnknownRecord,
runtime: t.UnknownRecord,
dynamic: t.union([t.literal(true), t.literal(false), t.literal('strict')]),
date_detection: t.boolean,
numeric_detection: t.boolean,
@ -215,17 +222,23 @@ export const mappingsConfigurationSchema = t.exact(
_routing: t.interface({
required: t.boolean,
}),
dynamic_templates: t.array(t.UnknownRecord),
// Mapper size plugin
_size: t.interface({
enabled: t.boolean,
}),
})
);
const mappingsConfigurationSchemaKeys = Object.keys(mappingsConfigurationSchema.type.props);
export const mappingsConfigurationSchemaKeys = Object.keys(mappingsConfigurationSchema.type.props);
const sourceConfigurationSchemaKeys = Object.keys(
mappingsConfigurationSchema.type.props._source.type.props
);
export const validateMappingsConfiguration = (
mappingsConfiguration: any
): { value: any; errors: MappingsValidationError[] } => {
mappingsConfiguration: GenericObject
): ValidationResponse => {
// Set to keep track of invalid configuration parameters.
const configurationRemoved: Set<string> = new Set();
@ -277,17 +290,56 @@ export const validateMappingsConfiguration = (
return { value: copyOfMappingsConfig, errors };
};
export const validateMappings = (mappings: any = {}): MappingsValidatorResponse => {
const validatePluginsParameters =
(esNodesPlugins: string[]) =>
(mappingsConfiguration: GenericObject): ValidationResponse => {
const copyOfMappingsConfig = { ...mappingsConfiguration };
const errors: MappingsValidationError[] = [];
// Mapper size plugin parameters
if ('_size' in copyOfMappingsConfig && !esNodesPlugins.includes(MapperSizePluginId)) {
errors.push({
code: 'ERR_CONFIG',
configName: '_size',
});
delete copyOfMappingsConfig._size;
}
return { value: copyOfMappingsConfig, errors };
};
export const validateMappings = (
mappings: unknown = {},
esNodesPlugins: string[] = []
): MappingsValidatorResponse => {
if (!isPlainObject(mappings)) {
return { value: {} };
}
const { properties, dynamic_templates: dynamicTemplates, ...mappingsConfiguration } = mappings;
const {
properties,
dynamic_templates: dynamicTemplates,
...mappingsConfiguration // extract the mappings configuration
} = mappings as GenericObject;
// Run the different validators on the mappings configuration. Each validator returns
// the mapping configuration sanitized (in "value") and possible errors found.
const { value: parsedConfiguration, errors: configurationErrors } = [
validateMappingsConfiguration,
validatePluginsParameters(esNodesPlugins),
].reduce<ValidationResponse>(
(acc, validator) => {
const { value: sanitizedConfiguration, errors: validationErrors } = validator(acc.value);
return {
value: sanitizedConfiguration,
errors: [...acc.errors, ...validationErrors],
};
},
{ value: mappingsConfiguration, errors: [] }
);
const { value: parsedConfiguration, errors: configurationErrors } =
validateMappingsConfiguration(mappingsConfiguration);
const { value: parsedProperties, errors: propertiesErrors } = validateProperties(properties);
const errors = [...configurationErrors, ...propertiesErrors];
return {
@ -299,10 +351,3 @@ export const validateMappings = (mappings: any = {}): MappingsValidatorResponse
errors: errors.length ? errors : undefined,
};
};
export const VALID_MAPPINGS_PARAMETERS = [
...mappingsConfigurationSchemaKeys,
'dynamic_templates',
'properties',
'runtime',
];

View file

@ -43,166 +43,177 @@ interface MappingsEditorParsedMetadata {
multipleMappingsDeclared: boolean;
}
interface Props {
export interface Props {
onChange: OnUpdateHandler;
value?: { [key: string]: any };
indexSettings?: IndexSettings;
docLinks: DocLinksStart;
/** List of plugins installed in the cluster nodes */
esNodesPlugins: string[];
}
export const MappingsEditor = React.memo(({ onChange, value, docLinks, indexSettings }: Props) => {
const { parsedDefaultValue, multipleMappingsDeclared } =
useMemo<MappingsEditorParsedMetadata>(() => {
const mappingsDefinition = extractMappingsDefinition(value);
export const MappingsEditor = React.memo(
({ onChange, value, docLinks, indexSettings, esNodesPlugins }: Props) => {
const { parsedDefaultValue, multipleMappingsDeclared } =
useMemo<MappingsEditorParsedMetadata>(() => {
const mappingsDefinition = extractMappingsDefinition(value);
if (mappingsDefinition === null) {
return { multipleMappingsDeclared: true };
}
if (mappingsDefinition === null) {
return { multipleMappingsDeclared: true };
}
const {
_source,
_meta,
_routing,
dynamic,
properties,
runtime,
/* eslint-disable @typescript-eslint/naming-convention */
numeric_detection,
date_detection,
dynamic_date_formats,
dynamic_templates,
/* eslint-enable @typescript-eslint/naming-convention */
} = mappingsDefinition;
const parsed = {
configuration: {
const {
_source,
_meta,
_routing,
_size,
dynamic,
properties,
runtime,
/* eslint-disable @typescript-eslint/naming-convention */
numeric_detection,
date_detection,
dynamic_date_formats,
},
fields: properties,
templates: {
dynamic_templates,
},
runtime,
};
/* eslint-enable @typescript-eslint/naming-convention */
} = mappingsDefinition;
return { parsedDefaultValue: parsed, multipleMappingsDeclared: false };
}, [value]);
const parsed = {
configuration: {
_source,
_meta,
_routing,
_size,
dynamic,
numeric_detection,
date_detection,
dynamic_date_formats,
},
fields: properties,
templates: {
dynamic_templates,
},
runtime,
};
/**
* Hook that will listen to:
* 1. "value" prop changes in order to reset the mappings editor
* 2. "state" changes in order to communicate any updates to the consumer
*/
useMappingsStateListener({ onChange, value: parsedDefaultValue });
return { parsedDefaultValue: parsed, multipleMappingsDeclared: false };
}, [value]);
const { update: updateConfig } = useConfig();
const state = useMappingsState();
const [selectedTab, selectTab] = useState<TabName>('fields');
/**
* Hook that will listen to:
* 1. "value" prop changes in order to reset the mappings editor
* 2. "state" changes in order to communicate any updates to the consumer
*/
useMappingsStateListener({ onChange, value: parsedDefaultValue });
useEffect(() => {
if (multipleMappingsDeclared) {
// We set the data getter here as the user won't be able to make any changes
onChange({
getData: () => value! as Mappings,
validate: () => Promise.resolve(true),
isValid: true,
const { update: updateConfig } = useConfig();
const state = useMappingsState();
const [selectedTab, selectTab] = useState<TabName>('fields');
useEffect(() => {
if (multipleMappingsDeclared) {
// We set the data getter here as the user won't be able to make any changes
onChange({
getData: () => value! as Mappings,
validate: () => Promise.resolve(true),
isValid: true,
});
}
}, [multipleMappingsDeclared, onChange, value]);
useEffect(() => {
// Update the the config context so it is available globally (e.g in our Global flyout)
updateConfig({
docLinks,
indexSettings: indexSettings ?? {},
});
}
}, [multipleMappingsDeclared, onChange, value]);
}, [updateConfig, docLinks, indexSettings]);
useEffect(() => {
// Update the the config context so it is available globally (e.g in our Global flyout)
updateConfig({
docLinks,
indexSettings: indexSettings ?? {},
});
}, [updateConfig, docLinks, indexSettings]);
const changeTab = async (tab: TabName) => {
if (selectedTab === 'advanced') {
// When we navigate away we need to submit the form to validate if there are any errors.
const { isValid: isConfigurationFormValid } = await state.configuration.submitForm!();
const changeTab = async (tab: TabName) => {
if (selectedTab === 'advanced') {
// When we navigate away we need to submit the form to validate if there are any errors.
const { isValid: isConfigurationFormValid } = await state.configuration.submitForm!();
if (!isConfigurationFormValid) {
/**
* Don't navigate away from the tab if there are errors in the form.
*/
return;
}
} else if (selectedTab === 'templates') {
const { isValid: isTemplatesFormValid } = await state.templates.submitForm!();
if (!isConfigurationFormValid) {
/**
* Don't navigate away from the tab if there are errors in the form.
*/
return;
if (!isTemplatesFormValid) {
return;
}
}
} else if (selectedTab === 'templates') {
const { isValid: isTemplatesFormValid } = await state.templates.submitForm!();
if (!isTemplatesFormValid) {
return;
}
}
selectTab(tab);
};
selectTab(tab);
};
const tabToContentMap = {
fields: <DocumentFields />,
runtimeFields: <RuntimeFieldsList />,
templates: <TemplatesForm value={state.templates.defaultValue} />,
advanced: (
<ConfigurationForm
value={state.configuration.defaultValue}
esNodesPlugins={esNodesPlugins}
/>
),
};
const tabToContentMap = {
fields: <DocumentFields />,
runtimeFields: <RuntimeFieldsList />,
templates: <TemplatesForm value={state.templates.defaultValue} />,
advanced: <ConfigurationForm value={state.configuration.defaultValue} />,
};
return (
<div data-test-subj="mappingsEditor">
{multipleMappingsDeclared ? (
<MultipleMappingsWarning />
) : (
<div className="mappingsEditor">
<EuiTabs>
<EuiTab
onClick={() => changeTab('fields')}
isSelected={selectedTab === 'fields'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', {
defaultMessage: 'Mapped fields',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('runtimeFields')}
isSelected={selectedTab === 'runtimeFields'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.runtimeFieldsTabLabel', {
defaultMessage: 'Runtime fields',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('templates')}
isSelected={selectedTab === 'templates'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', {
defaultMessage: 'Dynamic templates',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('advanced')}
isSelected={selectedTab === 'advanced'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', {
defaultMessage: 'Advanced options',
})}
</EuiTab>
</EuiTabs>
return (
<div data-test-subj="mappingsEditor">
{multipleMappingsDeclared ? (
<MultipleMappingsWarning />
) : (
<div className="mappingsEditor">
<EuiTabs>
<EuiTab
onClick={() => changeTab('fields')}
isSelected={selectedTab === 'fields'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', {
defaultMessage: 'Mapped fields',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('runtimeFields')}
isSelected={selectedTab === 'runtimeFields'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.runtimeFieldsTabLabel', {
defaultMessage: 'Runtime fields',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('templates')}
isSelected={selectedTab === 'templates'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', {
defaultMessage: 'Dynamic templates',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('advanced')}
isSelected={selectedTab === 'advanced'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', {
defaultMessage: 'Advanced options',
})}
</EuiTab>
</EuiTabs>
<EuiSpacer size="l" />
<EuiSpacer size="l" />
{tabToContentMap[selectedTab]}
</div>
)}
</div>
);
});
{tabToContentMap[selectedTab]}
</div>
)}
</div>
);
}
);

View file

@ -31,6 +31,7 @@ export interface MappingsConfiguration {
excludes?: string[];
};
_meta?: string;
_size?: { enabled: boolean };
}
export interface MappingsTemplates {

View file

@ -28,12 +28,13 @@ import {
interface Props {
onChange: (content: Forms.Content) => void;
esDocsBase: string;
esNodesPlugins: string[];
defaultValue?: { [key: string]: any };
indexSettings?: IndexSettings;
}
export const StepMappings: React.FunctionComponent<Props> = React.memo(
({ defaultValue = {}, onChange, indexSettings, esDocsBase }) => {
({ defaultValue = {}, onChange, indexSettings, esDocsBase, esNodesPlugins }) => {
const [mappings, setMappings] = useState(defaultValue);
const { docLinks } = useAppContext();
@ -82,7 +83,7 @@ export const StepMappings: React.FunctionComponent<Props> = React.memo(
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<LoadMappingsFromJsonButton onJson={onJsonLoaded} />
<LoadMappingsFromJsonButton onJson={onJsonLoaded} esNodesPlugins={esNodesPlugins} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -111,6 +112,7 @@ export const StepMappings: React.FunctionComponent<Props> = React.memo(
onChange={onMappingsEditorUpdate}
indexSettings={indexSettings}
docLinks={docLinks}
esNodesPlugins={esNodesPlugins}
/>
<EuiSpacer size="m" />

View file

@ -8,6 +8,7 @@
import React from 'react';
import { Forms } from '../../../../../shared_imports';
import { useLoadNodesPlugins } from '../../../../services';
import { CommonWizardSteps } from './types';
import { StepMappings } from './step_mappings';
@ -20,6 +21,7 @@ export const StepMappingsContainer: React.FunctionComponent<Props> = ({ esDocsBa
CommonWizardSteps,
'mappings'
>('mappings');
const { data: esNodesPlugins } = useLoadNodesPlugins();
return (
<StepMappings
@ -27,6 +29,7 @@ export const StepMappingsContainer: React.FunctionComponent<Props> = ({ esDocsBa
onChange={updateContent}
indexSettings={getSingleContentData('settings')}
esDocsBase={esDocsBase}
esNodesPlugins={esNodesPlugins ?? []}
/>
);
};

View file

@ -175,6 +175,7 @@ export async function clearCacheIndices(indices: string[]) {
uiMetricService.trackMetric(METRIC_TYPE.COUNT, eventName);
return response;
}
export async function unfreezeIndices(indices: string[]) {
const body = JSON.stringify({
indices,
@ -303,3 +304,10 @@ export function simulateIndexTemplate(template: { [key: string]: any }) {
return result;
});
}
export function useLoadNodesPlugins() {
return useRequest<string[]>({
path: `${API_BASE_PATH}/nodes/plugins`,
method: 'get',
});
}

View file

@ -10,6 +10,8 @@ import { DataType } from '../components/mappings_editor/types';
import { TYPE_DEFINITION } from '../components/mappings_editor/constants';
class DocumentationService {
private links: DocLinksStart['links'] | undefined;
private dataStreams: string = '';
private esDocsBase: string = '';
private indexManagement: string = '';
@ -55,8 +57,11 @@ class DocumentationService {
private mappingTypesRemoval: string = '';
private percolate: string = '';
private runtimeFields: string = '';
public setup(docLinks: DocLinksStart): void {
const { links } = docLinks;
this.links = links;
this.dataStreams = links.elasticsearch.dataStreams;
this.esDocsBase = links.elasticsearch.docsBase;
this.indexManagement = links.management.indexManagement;
@ -301,6 +306,13 @@ class DocumentationService {
public getRootLocaleLink() {
return 'https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#ROOT';
}
public get docLinks(): DocLinksStart['links'] {
if (!this.links) {
throw new Error(`Can't return undefined doc links.`);
}
return this.links;
}
}
export const documentationService = new DocumentationService();

View file

@ -23,6 +23,7 @@ export {
loadIndexData,
useLoadIndexTemplates,
simulateIndexTemplate,
useLoadNodesPlugins,
} from './api';
export { sortTable } from './sort_table';

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 { registerNodesRoute } from './register_nodes_route';

View file

@ -0,0 +1,48 @@
/*
* 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 '../index';
import { registerNodesRoute } from './register_nodes_route';
import { RouterMock, routeDependencies, RequestMock } from '../../../test/helpers';
describe('[Index management API Routes] Nodes info', () => {
const router = new RouterMock();
const getNodesInfo = router.getMockESApiFn('nodes.info');
beforeAll(() => {
registerNodesRoute({
...routeDependencies,
router,
});
});
test('getNodesPlugins()', async () => {
const mockRequest: RequestMock = {
method: 'get',
path: addBasePath('/nodes/plugins'),
};
// Mock the response from the ES client ('nodes.info()')
getNodesInfo.mockResolvedValue({
body: {
nodes: {
node1: {
plugins: [{ name: 'plugin-1' }, { name: 'plugin-2' }],
},
node2: {
plugins: [{ name: 'plugin-1' }, { name: 'plugin-3' }],
},
},
},
});
await expect(router.runRequest(mockRequest)).resolves.toEqual({
body: ['plugin-1', 'plugin-2', 'plugin-3'],
});
});
});

View file

@ -0,0 +1,33 @@
/*
* 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 { RouteDependencies } from '../../../types';
import { addBasePath } from '../index';
export function registerNodesRoute({ router, lib: { handleEsError } }: RouteDependencies) {
// Retrieve the es plugins installed on the cluster nodes
router.get(
{ path: addBasePath('/nodes/plugins'), validate: {} },
async (context, request, response) => {
const { client } = context.core.elasticsearch;
try {
const { body } = await client.asCurrentUser.nodes.info();
const plugins: Set<string> = Object.values(body.nodes).reduce((acc, nodeInfo) => {
nodeInfo.plugins?.forEach(({ name }) => {
acc.add(name);
});
return acc;
}, new Set<string>());
return response.ok({ body: Array.from(plugins) });
} catch (error) {
return handleEsError({ error, response });
}
}
);
}

View file

@ -14,6 +14,7 @@ import { registerMappingRoute } from './api/mapping';
import { registerSettingsRoutes } from './api/settings';
import { registerStatsRoute } from './api/stats';
import { registerComponentTemplateRoutes } from './api/component_templates';
import { registerNodesRoute } from './api/nodes';
export class ApiRoutes {
setup(dependencies: RouteDependencies) {
@ -24,6 +25,7 @@ export class ApiRoutes {
registerStatsRoute(dependencies);
registerMappingRoute(dependencies);
registerComponentTemplateRoutes(dependencies);
registerNodesRoute(dependencies);
}
start() {}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export type { RequestMock } from './router_mock';
export { RouterMock } from './router_mock';
export { routeDependencies } from './route_dependencies';

View file

@ -0,0 +1,20 @@
/*
* 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 { handleEsError } from '../../shared_imports';
import { IndexDataEnricher } from '../../services';
import type { RouteDependencies } from '../../types';
export const routeDependencies: Omit<RouteDependencies, 'router'> = {
config: {
isSecurityEnabled: jest.fn().mockReturnValue(true),
},
indexDataEnricher: new IndexDataEnricher(),
lib: {
handleEsError,
},
};

View file

@ -0,0 +1,107 @@
/*
* 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 type { IRouter } from 'src/core/server';
import { get } from 'lodash';
import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks';
type RequestHandler = (...params: any[]) => any;
type RequestMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
interface HandlersByUrl {
[key: string]: RequestHandler;
}
const responseIntercepted = {
ok(response: any) {
return response;
},
conflict(response: any) {
response.status = 409;
return response;
},
internalError(response: any) {
response.status = 500;
return response;
},
notFound(response: any) {
response.status = 404;
return response;
},
};
/**
* Create a proxy with a default "catch all" handler to make sure we don't break route handlers that make use
* of other method on the response object that the ones defined in `responseIntercepted` above.
*/
const responseMock = new Proxy(responseIntercepted, {
get: (target: any, prop) => (prop in target ? target[prop] : (response: any) => response),
has: () => true,
});
export interface RequestMock {
method: RequestMethod;
path: string;
[key: string]: any;
}
export class RouterMock implements IRouter {
/**
* Cache to keep a reference to all the request handler defined on the router for each HTTP method and path
*/
private cacheHandlers: { [key in RequestMethod]: HandlersByUrl } = {
get: {},
post: {},
put: {},
delete: {},
patch: {},
};
public contextMock = {
core: { elasticsearch: { client: elasticsearchServiceMock.createScopedClusterClient() } },
};
getRoutes = jest.fn();
handleLegacyErrors = jest.fn();
routerPath = '';
get({ path }: { path: string }, handler: RequestHandler) {
this.cacheHandlers.get[path] = handler;
}
post({ path }: { path: string }, handler: RequestHandler) {
this.cacheHandlers.post[path] = handler;
}
put({ path }: { path: string }, handler: RequestHandler) {
this.cacheHandlers.put[path] = handler;
}
delete({ path }: { path: string }, handler: RequestHandler) {
this.cacheHandlers.delete[path] = handler;
}
patch({ path }: { path: string }, handler: RequestHandler) {
this.cacheHandlers.patch[path] = handler;
}
getMockESApiFn(path: string): jest.Mock {
return get(this.contextMock.core.elasticsearch.client.asCurrentUser, path);
}
runRequest({ method, path, ...mockRequest }: RequestMock) {
const handler = this.cacheHandlers[method][path];
if (typeof handler !== 'function') {
throw new Error(`No route handler found for ${method.toUpperCase()} request at "${path}"`);
}
return handler(this.contextMock, mockRequest, responseMock);
}
}

View file

@ -0,0 +1,16 @@
/*
* 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 { API_BASE_PATH } from './constants';
export const registerHelpers = ({ supertest }: { supertest: any }) => {
const getNodesPlugins = () => supertest.get(`${API_BASE_PATH}/nodes/plugins`);
return {
getNodesPlugins,
};
};

View file

@ -0,0 +1,25 @@
/*
* 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';
import { registerHelpers } from './cluster_nodes.helpers';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const { getNodesPlugins } = registerHelpers({ supertest });
describe('nodes', () => {
it('should fetch the nodes plugins', async () => {
const { body } = await getNodesPlugins().expect(200);
expect(body).eql([]);
});
});
}

View file

@ -14,5 +14,6 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./data_streams'));
loadTestFile(require.resolve('./templates'));
loadTestFile(require.resolve('./component_templates'));
loadTestFile(require.resolve('./cluster_nodes'));
});
}