mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[Mappings editor] Add support for the _size parameter (#119365)
This commit is contained in:
parent
673039d89d
commit
d0f7d7c21d
36 changed files with 712 additions and 181 deletions
|
@ -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<{
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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)', () => {
|
||||
|
|
|
@ -339,4 +339,6 @@ export type TestSubjects =
|
|||
| 'versionField'
|
||||
| 'aliasesEditor'
|
||||
| 'settingsEditor'
|
||||
| 'versionField.input';
|
||||
| 'versionField.input'
|
||||
| 'mappingsEditor.formTab'
|
||||
| 'mappingsEditor.advancedConfiguration.sizeEnabledToggle';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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', {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -14,3 +14,5 @@ export * from './data_types_definition';
|
|||
export * from './parameters_definition';
|
||||
|
||||
export * from './mappings_editor';
|
||||
|
||||
export const MapperSizePluginId = 'mapper-size';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -31,6 +31,7 @@ export interface MappingsConfiguration {
|
|||
excludes?: string[];
|
||||
};
|
||||
_meta?: string;
|
||||
_size?: { enabled: boolean };
|
||||
}
|
||||
|
||||
export interface MappingsTemplates {
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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 ?? []}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -23,6 +23,7 @@ export {
|
|||
loadIndexData,
|
||||
useLoadIndexTemplates,
|
||||
simulateIndexTemplate,
|
||||
useLoadNodesPlugins,
|
||||
} from './api';
|
||||
export { sortTable } from './sort_table';
|
||||
|
||||
|
|
|
@ -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';
|
|
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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() {}
|
||||
|
|
11
x-pack/plugins/index_management/server/test/helpers/index.ts
Normal file
11
x-pack/plugins/index_management/server/test/helpers/index.ts
Normal 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';
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue