mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[ML] add geo point combined field to CSV import (#77117)
* [ML] add geo point combined field to CSV import * remove some geo_point specific logic * Account for properties layer in find_file_structure mappings * improve checking of name collision to include combined fields and mappings * add delete button * fix function name * fill in unknowns with defined types * tslint changes * get tslint passing * show readonly combined fields in simple tab * handle column_names being undefined * add unit tests for modifying mappings and pipeline * review feedback * do not change combinedFields on reset Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
3d67eaaed4
commit
f3c9d10afc
13 changed files with 986 additions and 3 deletions
|
@ -29,6 +29,8 @@ export interface FindFileStructureResponse {
|
|||
count: number;
|
||||
cardinality: number;
|
||||
top_hits: Array<{ count: number; value: any }>;
|
||||
max_value?: number;
|
||||
min_value?: number;
|
||||
};
|
||||
};
|
||||
sample_start: string;
|
||||
|
@ -42,7 +44,7 @@ export interface FindFileStructureResponse {
|
|||
delimiter: string;
|
||||
need_client_timezone: boolean;
|
||||
num_lines_analyzed: number;
|
||||
column_names: string[];
|
||||
column_names?: string[];
|
||||
explanation?: string[];
|
||||
grok_pattern?: string;
|
||||
multiline_start_pattern?: string;
|
||||
|
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiText } from '@elastic/eui';
|
||||
|
||||
import { CombinedField } from './types';
|
||||
|
||||
export function CombinedFieldLabel({ combinedField }: { combinedField: CombinedField }) {
|
||||
return <EuiText size="s">{getCombinedFieldLabel(combinedField)}</EuiText>;
|
||||
}
|
||||
|
||||
function getCombinedFieldLabel(combinedField: CombinedField) {
|
||||
return `${combinedField.fieldNames.join(combinedField.delimiter)} => ${
|
||||
combinedField.combinedFieldName
|
||||
} (${combinedField.mappingType})`;
|
||||
}
|
|
@ -0,0 +1,237 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {
|
||||
EuiFormRow,
|
||||
EuiPopover,
|
||||
EuiContextMenu,
|
||||
EuiButtonEmpty,
|
||||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { CombinedField } from './types';
|
||||
import { GeoPointForm } from './geo_point';
|
||||
import { CombinedFieldLabel } from './combined_field_label';
|
||||
import {
|
||||
addCombinedFieldsToMappings,
|
||||
addCombinedFieldsToPipeline,
|
||||
getNameCollisionMsg,
|
||||
removeCombinedFieldsFromMappings,
|
||||
removeCombinedFieldsFromPipeline,
|
||||
} from './utils';
|
||||
import { FindFileStructureResponse } from '../../../../../../common/types/file_datavisualizer';
|
||||
|
||||
interface Props {
|
||||
mappingsString: string;
|
||||
pipelineString: string;
|
||||
onMappingsStringChange(): void;
|
||||
onPipelineStringChange(): void;
|
||||
combinedFields: CombinedField[];
|
||||
onCombinedFieldsChange(combinedFields: CombinedField[]): void;
|
||||
results: FindFileStructureResponse;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isPopoverOpen: boolean;
|
||||
}
|
||||
|
||||
export class CombinedFieldsForm extends Component<Props, State> {
|
||||
state: State = {
|
||||
isPopoverOpen: false,
|
||||
};
|
||||
|
||||
togglePopover = () => {
|
||||
this.setState((prevState) => ({
|
||||
isPopoverOpen: !prevState.isPopoverOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
closePopover = () => {
|
||||
this.setState({
|
||||
isPopoverOpen: false,
|
||||
});
|
||||
};
|
||||
|
||||
addCombinedField = (combinedField: CombinedField) => {
|
||||
if (this.hasNameCollision(combinedField.combinedFieldName)) {
|
||||
throw new Error(getNameCollisionMsg(combinedField.combinedFieldName));
|
||||
}
|
||||
|
||||
const mappings = this.parseMappings();
|
||||
const pipeline = this.parsePipeline();
|
||||
|
||||
this.props.onMappingsStringChange(
|
||||
// @ts-expect-error
|
||||
JSON.stringify(addCombinedFieldsToMappings(mappings, [combinedField]), null, 2)
|
||||
);
|
||||
this.props.onPipelineStringChange(
|
||||
// @ts-expect-error
|
||||
JSON.stringify(addCombinedFieldsToPipeline(pipeline, [combinedField]), null, 2)
|
||||
);
|
||||
this.props.onCombinedFieldsChange([...this.props.combinedFields, combinedField]);
|
||||
|
||||
this.closePopover();
|
||||
};
|
||||
|
||||
removeCombinedField = (index: number) => {
|
||||
let mappings;
|
||||
let pipeline;
|
||||
try {
|
||||
mappings = this.parseMappings();
|
||||
pipeline = this.parsePipeline();
|
||||
} catch (error) {
|
||||
// how should remove error be surfaced?
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedCombinedFields = [...this.props.combinedFields];
|
||||
const removedCombinedFields = updatedCombinedFields.splice(index, 1);
|
||||
|
||||
this.props.onMappingsStringChange(
|
||||
// @ts-expect-error
|
||||
JSON.stringify(removeCombinedFieldsFromMappings(mappings, removedCombinedFields), null, 2)
|
||||
);
|
||||
this.props.onPipelineStringChange(
|
||||
// @ts-expect-error
|
||||
JSON.stringify(removeCombinedFieldsFromPipeline(pipeline, removedCombinedFields), null, 2)
|
||||
);
|
||||
this.props.onCombinedFieldsChange(updatedCombinedFields);
|
||||
};
|
||||
|
||||
parseMappings() {
|
||||
try {
|
||||
return JSON.parse(this.props.mappingsString);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
i18n.translate('xpack.ml.fileDatavisualizer.combinedFieldsForm.mappingsParseError', {
|
||||
defaultMessage: 'Error parsing mappings: {error}',
|
||||
values: { error: error.message },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
parsePipeline() {
|
||||
try {
|
||||
return JSON.parse(this.props.pipelineString);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
i18n.translate('xpack.ml.fileDatavisualizer.combinedFieldsForm.pipelineParseError', {
|
||||
defaultMessage: 'Error parsing pipeline: {error}',
|
||||
values: { error: error.message },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hasNameCollision = (name: string) => {
|
||||
if (this.props.results.column_names?.includes(name)) {
|
||||
// collision with column name
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
this.props.combinedFields.some((combinedField) => combinedField.combinedFieldName === name)
|
||||
) {
|
||||
// collision with combined field name
|
||||
return true;
|
||||
}
|
||||
|
||||
const mappings = this.parseMappings();
|
||||
return mappings.properties.hasOwnProperty(name);
|
||||
};
|
||||
|
||||
render() {
|
||||
const geoPointLabel = i18n.translate('xpack.ml.fileDatavisualizer.geoPointCombinedFieldLabel', {
|
||||
defaultMessage: 'Add geo point field',
|
||||
});
|
||||
const panels = [
|
||||
{
|
||||
id: 0,
|
||||
items: [
|
||||
{
|
||||
name: geoPointLabel,
|
||||
panel: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: geoPointLabel,
|
||||
content: (
|
||||
<GeoPointForm
|
||||
addCombinedField={this.addCombinedField}
|
||||
hasNameCollision={this.hasNameCollision}
|
||||
results={this.props.results}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.fileDatavisualizer.combinedFieldsLabel', {
|
||||
defaultMessage: 'Combined fields',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{this.props.combinedFields.map((combinedField: CombinedField, idx: number) => (
|
||||
<EuiFlexGroup key={idx} gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<CombinedFieldLabel combinedField={combinedField} />
|
||||
</EuiFlexItem>
|
||||
{!this.props.isDisabled && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
iconType="trash"
|
||||
color="danger"
|
||||
onClick={this.removeCombinedField.bind(null, idx)}
|
||||
title={i18n.translate('xpack.ml.fileDatavisualizer.removeCombinedFieldsLabel', {
|
||||
defaultMessage: 'Remove combined field',
|
||||
})}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.ml.fileDatavisualizer.removeCombinedFieldsLabel',
|
||||
{
|
||||
defaultMessage: 'Remove combined field',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
))}
|
||||
<EuiPopover
|
||||
id="combineFieldsPopover"
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
onClick={this.togglePopover}
|
||||
size="xs"
|
||||
iconType="plusInCircleFilled"
|
||||
isDisabled={this.props.isDisabled}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.fileDatavisualizer.addCombinedFieldsLabel"
|
||||
defaultMessage="Add combined field"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
anchorPosition="rightCenter"
|
||||
>
|
||||
<EuiContextMenu initialPanelId={0} panels={panels} />
|
||||
</EuiPopover>
|
||||
</div>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
|
||||
import { EuiFormRow } from '@elastic/eui';
|
||||
|
||||
import { CombinedField } from './types';
|
||||
import { CombinedFieldLabel } from './combined_field_label';
|
||||
|
||||
export function CombinedFieldsReadOnlyForm({
|
||||
combinedFields,
|
||||
}: {
|
||||
combinedFields: CombinedField[];
|
||||
}) {
|
||||
return combinedFields.length ? (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.fileDatavisualizer.combinedFieldsReadOnlyLabel', {
|
||||
defaultMessage: 'Combined fields',
|
||||
})}
|
||||
helpText={i18n.translate('xpack.ml.fileDatavisualizer.combinedFieldsReadOnlyHelpTextLabel', {
|
||||
defaultMessage: 'Edit combined fields in advanced tab',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{combinedFields.map((combinedField: CombinedField, idx: number) => (
|
||||
<CombinedFieldLabel key={idx} combinedField={combinedField} />
|
||||
))}
|
||||
</div>
|
||||
</EuiFormRow>
|
||||
) : null;
|
||||
}
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import React, { ChangeEvent, Component, Fragment } from 'react';
|
||||
|
||||
import {
|
||||
EuiFormRow,
|
||||
EuiFieldText,
|
||||
EuiTextAlign,
|
||||
EuiSpacer,
|
||||
EuiButton,
|
||||
EuiSelect,
|
||||
EuiSelectOption,
|
||||
EuiFormErrorText,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { CombinedField } from './types';
|
||||
import {
|
||||
createGeoPointCombinedField,
|
||||
isWithinLatRange,
|
||||
isWithinLonRange,
|
||||
getFieldNames,
|
||||
getNameCollisionMsg,
|
||||
} from './utils';
|
||||
import { FindFileStructureResponse } from '../../../../../../common/types/file_datavisualizer';
|
||||
|
||||
interface Props {
|
||||
addCombinedField: (combinedField: CombinedField) => void;
|
||||
hasNameCollision: (name: string) => boolean;
|
||||
results: FindFileStructureResponse;
|
||||
}
|
||||
|
||||
interface State {
|
||||
latField: string;
|
||||
lonField: string;
|
||||
geoPointField: string;
|
||||
geoPointFieldError: string;
|
||||
latFields: EuiSelectOption[];
|
||||
lonFields: EuiSelectOption[];
|
||||
submitError: string;
|
||||
}
|
||||
|
||||
export class GeoPointForm extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const latFields: EuiSelectOption[] = [{ value: '', text: '' }];
|
||||
const lonFields: EuiSelectOption[] = [{ value: '', text: '' }];
|
||||
getFieldNames(props.results).forEach((columnName: string) => {
|
||||
if (isWithinLatRange(columnName, props.results.field_stats)) {
|
||||
latFields.push({ value: columnName, text: columnName });
|
||||
}
|
||||
if (isWithinLonRange(columnName, props.results.field_stats)) {
|
||||
lonFields.push({ value: columnName, text: columnName });
|
||||
}
|
||||
});
|
||||
|
||||
this.state = {
|
||||
latField: '',
|
||||
lonField: '',
|
||||
geoPointField: '',
|
||||
geoPointFieldError: '',
|
||||
submitError: '',
|
||||
latFields,
|
||||
lonFields,
|
||||
};
|
||||
}
|
||||
|
||||
onLatFieldChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
this.setState({ latField: e.target.value });
|
||||
};
|
||||
|
||||
onLonFieldChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
this.setState({ lonField: e.target.value });
|
||||
};
|
||||
|
||||
onGeoPointFieldChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const geoPointField = e.target.value;
|
||||
this.setState({ geoPointField });
|
||||
this.hasNameCollision(geoPointField);
|
||||
};
|
||||
|
||||
hasNameCollision = debounce((name: string) => {
|
||||
try {
|
||||
const geoPointFieldError = this.props.hasNameCollision(name) ? getNameCollisionMsg(name) : '';
|
||||
this.setState({ geoPointFieldError });
|
||||
} catch (error) {
|
||||
this.setState({ submitError: error.message });
|
||||
}
|
||||
}, 200);
|
||||
|
||||
onSubmit = () => {
|
||||
try {
|
||||
this.props.addCombinedField(
|
||||
createGeoPointCombinedField(
|
||||
this.state.latField,
|
||||
this.state.lonField,
|
||||
this.state.geoPointField
|
||||
)
|
||||
);
|
||||
this.setState({ submitError: '' });
|
||||
} catch (error) {
|
||||
this.setState({ submitError: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
let error;
|
||||
if (this.state.submitError) {
|
||||
error = <EuiFormErrorText>{this.state.submitError}</EuiFormErrorText>;
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.fileDatavisualizer.geoPointForm.latFieldLabel', {
|
||||
defaultMessage: 'Latitude field',
|
||||
})}
|
||||
>
|
||||
<EuiSelect
|
||||
options={this.state.latFields}
|
||||
value={this.state.latField}
|
||||
onChange={this.onLatFieldChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.fileDatavisualizer.geoPointForm.lonFieldLabel', {
|
||||
defaultMessage: 'Longitude field',
|
||||
})}
|
||||
>
|
||||
<EuiSelect
|
||||
options={this.state.lonFields}
|
||||
value={this.state.lonField}
|
||||
onChange={this.onLonFieldChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.fileDatavisualizer.geoPointForm.geoPointFieldLabel', {
|
||||
defaultMessage: 'Geo point field',
|
||||
})}
|
||||
isInvalid={this.state.geoPointFieldError !== ''}
|
||||
error={[this.state.geoPointFieldError]}
|
||||
>
|
||||
<EuiFieldText
|
||||
value={this.state.geoPointField}
|
||||
onChange={this.onGeoPointFieldChange}
|
||||
isInvalid={this.state.geoPointFieldError !== ''}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.ml.fileDatavisualizer.geoPointForm.geoPointFieldAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Geo point field, required field',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
{error}
|
||||
|
||||
<EuiTextAlign textAlign="right">
|
||||
<EuiButton
|
||||
size="s"
|
||||
fill
|
||||
disabled={
|
||||
!this.state.latField ||
|
||||
!this.state.lonField ||
|
||||
!this.state.geoPointField ||
|
||||
this.state.geoPointFieldError !== ''
|
||||
}
|
||||
onClick={this.onSubmit}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.fileDatavisualizer.geoPointForm.submitButtonLabel"
|
||||
defaultMessage="Add"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiTextAlign>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export {
|
||||
addCombinedFieldsToPipeline,
|
||||
addCombinedFieldsToMappings,
|
||||
getDefaultCombinedFields,
|
||||
} from './utils';
|
||||
|
||||
export { CombinedFieldsReadOnlyForm } from './combined_fields_read_only_form';
|
||||
export { CombinedFieldsForm } from './combined_fields_form';
|
||||
export { CombinedField } from './types';
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export interface CombinedField {
|
||||
mappingType: string;
|
||||
delimiter: string;
|
||||
combinedFieldName: string;
|
||||
fieldNames: string[];
|
||||
}
|
|
@ -0,0 +1,235 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
addCombinedFieldsToMappings,
|
||||
addCombinedFieldsToPipeline,
|
||||
createGeoPointCombinedField,
|
||||
isWithinLatRange,
|
||||
isWithinLonRange,
|
||||
removeCombinedFieldsFromMappings,
|
||||
removeCombinedFieldsFromPipeline,
|
||||
} from './utils';
|
||||
|
||||
const combinedFields = [createGeoPointCombinedField('lat', 'lon', 'location')];
|
||||
|
||||
test('addCombinedFieldsToMappings', () => {
|
||||
const mappings = {
|
||||
_meta: {
|
||||
created_by: '',
|
||||
},
|
||||
properties: {
|
||||
lat: {
|
||||
type: 'number',
|
||||
},
|
||||
lon: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(addCombinedFieldsToMappings(mappings, combinedFields)).toEqual({
|
||||
_meta: {
|
||||
created_by: '',
|
||||
},
|
||||
properties: {
|
||||
lat: {
|
||||
type: 'number',
|
||||
},
|
||||
lon: {
|
||||
type: 'number',
|
||||
},
|
||||
location: {
|
||||
type: 'geo_point',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('removeCombinedFieldsFromMappings', () => {
|
||||
const mappings = {
|
||||
_meta: {
|
||||
created_by: '',
|
||||
},
|
||||
properties: {
|
||||
lat: {
|
||||
type: 'number',
|
||||
},
|
||||
lon: {
|
||||
type: 'number',
|
||||
},
|
||||
location: {
|
||||
type: 'geo_point',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(removeCombinedFieldsFromMappings(mappings, combinedFields)).toEqual({
|
||||
_meta: {
|
||||
created_by: '',
|
||||
},
|
||||
properties: {
|
||||
lat: {
|
||||
type: 'number',
|
||||
},
|
||||
lon: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('addCombinedFieldsToPipeline', () => {
|
||||
const pipeline = {
|
||||
description: '',
|
||||
processors: [
|
||||
{
|
||||
set: {
|
||||
field: 'anotherfield',
|
||||
value: '{{value}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(addCombinedFieldsToPipeline(pipeline, combinedFields)).toEqual({
|
||||
description: '',
|
||||
processors: [
|
||||
{
|
||||
set: {
|
||||
field: 'anotherfield',
|
||||
value: '{{value}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
set: {
|
||||
field: 'location',
|
||||
value: '{{lat}},{{lon}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('removeCombinedFieldsFromPipeline', () => {
|
||||
const pipeline = {
|
||||
description: '',
|
||||
processors: [
|
||||
{
|
||||
set: {
|
||||
field: 'anotherfield',
|
||||
value: '{{value}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
set: {
|
||||
field: 'location',
|
||||
value: '{{lat}},{{lon}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(removeCombinedFieldsFromPipeline(pipeline, combinedFields)).toEqual({
|
||||
description: '',
|
||||
processors: [
|
||||
{
|
||||
set: {
|
||||
field: 'anotherfield',
|
||||
value: '{{value}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('isWithinLatRange', () => {
|
||||
expect(isWithinLatRange('fieldAlpha', {})).toBe(false);
|
||||
expect(
|
||||
isWithinLatRange('fieldAlpha', {
|
||||
fieldAlpha: {
|
||||
count: 1,
|
||||
cardinality: 1,
|
||||
top_hits: [{ count: 1, value: 1 }],
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWithinLatRange('fieldAlpha', {
|
||||
fieldAlpha: {
|
||||
count: 1,
|
||||
cardinality: 1,
|
||||
top_hits: [{ count: 1, value: 100 }],
|
||||
max_value: 100,
|
||||
min_value: 0,
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWithinLatRange('fieldAlpha', {
|
||||
fieldAlpha: {
|
||||
count: 1,
|
||||
cardinality: 1,
|
||||
top_hits: [{ count: 1, value: -100 }],
|
||||
max_value: 0,
|
||||
min_value: -100,
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWithinLatRange('fieldAlpha', {
|
||||
fieldAlpha: {
|
||||
count: 1,
|
||||
cardinality: 1,
|
||||
top_hits: [{ count: 1, value: 0 }],
|
||||
max_value: 0,
|
||||
min_value: 0,
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('isWithinLonRange', () => {
|
||||
expect(isWithinLonRange('fieldAlpha', {})).toBe(false);
|
||||
expect(
|
||||
isWithinLonRange('fieldAlpha', {
|
||||
fieldAlpha: {
|
||||
count: 1,
|
||||
cardinality: 1,
|
||||
top_hits: [{ count: 1, value: 1 }],
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWithinLonRange('fieldAlpha', {
|
||||
fieldAlpha: {
|
||||
count: 1,
|
||||
cardinality: 1,
|
||||
top_hits: [{ count: 1, value: 200 }],
|
||||
max_value: 200,
|
||||
min_value: 0,
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWithinLonRange('fieldAlpha', {
|
||||
fieldAlpha: {
|
||||
count: 1,
|
||||
cardinality: 1,
|
||||
top_hits: [{ count: 1, value: -200 }],
|
||||
max_value: 0,
|
||||
min_value: -200,
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWithinLonRange('fieldAlpha', {
|
||||
fieldAlpha: {
|
||||
count: 1,
|
||||
cardinality: 1,
|
||||
top_hits: [{ count: 1, value: 0 }],
|
||||
max_value: 0,
|
||||
min_value: 0,
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import _ from 'lodash';
|
||||
import uuid from 'uuid/v4';
|
||||
import { CombinedField } from './types';
|
||||
import {
|
||||
FindFileStructureResponse,
|
||||
IngestPipeline,
|
||||
Mappings,
|
||||
} from '../../../../../../common/types/file_datavisualizer';
|
||||
|
||||
const COMMON_LAT_NAMES = ['latitude', 'lat'];
|
||||
const COMMON_LON_NAMES = ['longitude', 'long', 'lon'];
|
||||
|
||||
export function getDefaultCombinedFields(results: FindFileStructureResponse) {
|
||||
const combinedFields: CombinedField[] = [];
|
||||
const geoPointField = getGeoPointField(results);
|
||||
if (geoPointField) {
|
||||
combinedFields.push(geoPointField);
|
||||
}
|
||||
return combinedFields;
|
||||
}
|
||||
|
||||
export function addCombinedFieldsToMappings(
|
||||
mappings: Mappings,
|
||||
combinedFields: CombinedField[]
|
||||
): Mappings {
|
||||
const updatedMappings = { ...mappings };
|
||||
combinedFields.forEach((combinedField) => {
|
||||
updatedMappings.properties[combinedField.combinedFieldName] = {
|
||||
type: combinedField.mappingType,
|
||||
};
|
||||
});
|
||||
return updatedMappings;
|
||||
}
|
||||
|
||||
export function removeCombinedFieldsFromMappings(
|
||||
mappings: Mappings,
|
||||
combinedFields: CombinedField[]
|
||||
) {
|
||||
const updatedMappings = { ...mappings };
|
||||
combinedFields.forEach((combinedField) => {
|
||||
delete updatedMappings.properties[combinedField.combinedFieldName];
|
||||
});
|
||||
return updatedMappings;
|
||||
}
|
||||
|
||||
export function addCombinedFieldsToPipeline(
|
||||
pipeline: IngestPipeline,
|
||||
combinedFields: CombinedField[]
|
||||
) {
|
||||
const updatedPipeline = _.cloneDeep(pipeline);
|
||||
combinedFields.forEach((combinedField) => {
|
||||
updatedPipeline.processors.push({
|
||||
set: {
|
||||
field: combinedField.combinedFieldName,
|
||||
value: combinedField.fieldNames
|
||||
.map((fieldName) => {
|
||||
return `{{${fieldName}}}`;
|
||||
})
|
||||
.join(combinedField.delimiter),
|
||||
},
|
||||
});
|
||||
});
|
||||
return updatedPipeline;
|
||||
}
|
||||
|
||||
export function removeCombinedFieldsFromPipeline(
|
||||
pipeline: IngestPipeline,
|
||||
combinedFields: CombinedField[]
|
||||
) {
|
||||
return {
|
||||
...pipeline,
|
||||
processors: pipeline.processors.filter((processor) => {
|
||||
return 'set' in processor
|
||||
? !combinedFields.some((combinedField) => {
|
||||
return processor.set.field === combinedField.combinedFieldName;
|
||||
})
|
||||
: true;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isWithinLatRange(
|
||||
fieldName: string,
|
||||
fieldStats: FindFileStructureResponse['field_stats']
|
||||
) {
|
||||
return (
|
||||
fieldName in fieldStats &&
|
||||
'max_value' in fieldStats[fieldName] &&
|
||||
fieldStats[fieldName]!.max_value! <= 90 &&
|
||||
'min_value' in fieldStats[fieldName] &&
|
||||
fieldStats[fieldName]!.min_value! >= -90
|
||||
);
|
||||
}
|
||||
|
||||
export function isWithinLonRange(
|
||||
fieldName: string,
|
||||
fieldStats: FindFileStructureResponse['field_stats']
|
||||
) {
|
||||
return (
|
||||
fieldName in fieldStats &&
|
||||
'max_value' in fieldStats[fieldName] &&
|
||||
fieldStats[fieldName]!.max_value! <= 180 &&
|
||||
'min_value' in fieldStats[fieldName] &&
|
||||
fieldStats[fieldName]!.min_value! >= -180
|
||||
);
|
||||
}
|
||||
|
||||
export function createGeoPointCombinedField(
|
||||
latField: string,
|
||||
lonField: string,
|
||||
geoPointField: string
|
||||
): CombinedField {
|
||||
return {
|
||||
mappingType: 'geo_point',
|
||||
delimiter: ',',
|
||||
combinedFieldName: geoPointField,
|
||||
fieldNames: [latField, lonField],
|
||||
};
|
||||
}
|
||||
|
||||
export function getNameCollisionMsg(name: string) {
|
||||
return i18n.translate('xpack.ml.fileDatavisualizer.nameCollisionMsg', {
|
||||
defaultMessage: '"{name}" already exists, please provide a unique name',
|
||||
values: { name },
|
||||
});
|
||||
}
|
||||
|
||||
export function getFieldNames(results: FindFileStructureResponse): string[] {
|
||||
return results.column_names !== undefined
|
||||
? results.column_names
|
||||
: Object.keys(results.field_stats);
|
||||
}
|
||||
|
||||
function getGeoPointField(results: FindFileStructureResponse) {
|
||||
const fieldNames = getFieldNames(results);
|
||||
|
||||
const latField = fieldNames.find((columnName) => {
|
||||
return (
|
||||
COMMON_LAT_NAMES.includes(columnName.toLowerCase()) &&
|
||||
isWithinLatRange(columnName, results.field_stats)
|
||||
);
|
||||
});
|
||||
|
||||
const lonField = fieldNames.find((columnName) => {
|
||||
return (
|
||||
COMMON_LON_NAMES.includes(columnName.toLowerCase()) &&
|
||||
isWithinLonRange(columnName, results.field_stats)
|
||||
);
|
||||
});
|
||||
|
||||
if (!latField || !lonField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const combinedFieldNames = [
|
||||
'location',
|
||||
'point_location',
|
||||
`${latField}_${lonField}`,
|
||||
`location_${uuid()}`,
|
||||
];
|
||||
// Use first combinedFieldNames that does not have a naming collision
|
||||
const geoPointField = combinedFieldNames.find((name) => {
|
||||
return !fieldNames.includes(name);
|
||||
});
|
||||
|
||||
return geoPointField ? createGeoPointCombinedField(latField, lonField, geoPointField) : null;
|
||||
}
|
|
@ -17,7 +17,9 @@ import {
|
|||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { CombinedField, CombinedFieldsForm } from '../combined_fields';
|
||||
import { MLJobEditor, ML_EDITOR_MODE } from '../../../../jobs/jobs_list/components/ml_job_editor';
|
||||
import { FindFileStructureResponse } from '../../../../../../common/types/file_datavisualizer';
|
||||
const EDITOR_HEIGHT = '300px';
|
||||
|
||||
interface Props {
|
||||
|
@ -36,6 +38,9 @@ interface Props {
|
|||
onPipelineStringChange(): void;
|
||||
indexNameError: string;
|
||||
indexPatternNameError: string;
|
||||
combinedFields: CombinedField[];
|
||||
onCombinedFieldsChange(combinedFields: CombinedField[]): void;
|
||||
results: FindFileStructureResponse;
|
||||
}
|
||||
|
||||
export const AdvancedSettings: FC<Props> = ({
|
||||
|
@ -54,6 +59,9 @@ export const AdvancedSettings: FC<Props> = ({
|
|||
onPipelineStringChange,
|
||||
indexNameError,
|
||||
indexPatternNameError,
|
||||
combinedFields,
|
||||
onCombinedFieldsChange,
|
||||
results,
|
||||
}) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
|
@ -123,6 +131,17 @@ export const AdvancedSettings: FC<Props> = ({
|
|||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<CombinedFieldsForm
|
||||
mappingsString={mappingsString}
|
||||
pipelineString={pipelineString}
|
||||
onMappingsStringChange={onMappingsStringChange}
|
||||
onPipelineStringChange={onPipelineStringChange}
|
||||
combinedFields={combinedFields}
|
||||
onCombinedFieldsChange={onCombinedFieldsChange}
|
||||
results={results}
|
||||
isDisabled={initialized === true}
|
||||
/>
|
||||
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<IndexSettings
|
||||
|
|
|
@ -11,6 +11,8 @@ import { EuiTabbedContent, EuiSpacer } from '@elastic/eui';
|
|||
|
||||
import { SimpleSettings } from './simple';
|
||||
import { AdvancedSettings } from './advanced';
|
||||
import { CombinedField } from '../combined_fields';
|
||||
import { FindFileStructureResponse } from '../../../../../../common/types/file_datavisualizer';
|
||||
|
||||
interface Props {
|
||||
index: string;
|
||||
|
@ -28,6 +30,9 @@ interface Props {
|
|||
onPipelineStringChange(): void;
|
||||
indexNameError: string;
|
||||
indexPatternNameError: string;
|
||||
combinedFields: CombinedField[];
|
||||
onCombinedFieldsChange(combinedFields: CombinedField[]): void;
|
||||
results: FindFileStructureResponse;
|
||||
}
|
||||
|
||||
export const ImportSettings: FC<Props> = ({
|
||||
|
@ -46,6 +51,9 @@ export const ImportSettings: FC<Props> = ({
|
|||
onPipelineStringChange,
|
||||
indexNameError,
|
||||
indexPatternNameError,
|
||||
combinedFields,
|
||||
onCombinedFieldsChange,
|
||||
results,
|
||||
}) => {
|
||||
const tabs = [
|
||||
{
|
||||
|
@ -64,6 +72,7 @@ export const ImportSettings: FC<Props> = ({
|
|||
createIndexPattern={createIndexPattern}
|
||||
onCreateIndexPatternChange={onCreateIndexPatternChange}
|
||||
indexNameError={indexNameError}
|
||||
combinedFields={combinedFields}
|
||||
/>
|
||||
</React.Fragment>
|
||||
),
|
||||
|
@ -93,6 +102,9 @@ export const ImportSettings: FC<Props> = ({
|
|||
onPipelineStringChange={onPipelineStringChange}
|
||||
indexNameError={indexNameError}
|
||||
indexPatternNameError={indexPatternNameError}
|
||||
combinedFields={combinedFields}
|
||||
onCombinedFieldsChange={onCombinedFieldsChange}
|
||||
results={results}
|
||||
/>
|
||||
</React.Fragment>
|
||||
),
|
||||
|
|
|
@ -9,6 +9,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { EuiFieldText, EuiFormRow, EuiCheckbox, EuiSpacer } from '@elastic/eui';
|
||||
import { CombinedField, CombinedFieldsReadOnlyForm } from '../combined_fields';
|
||||
|
||||
interface Props {
|
||||
index: string;
|
||||
|
@ -17,6 +18,7 @@ interface Props {
|
|||
createIndexPattern: boolean;
|
||||
onCreateIndexPatternChange(): void;
|
||||
indexNameError: string;
|
||||
combinedFields: CombinedField[];
|
||||
}
|
||||
|
||||
export const SimpleSettings: FC<Props> = ({
|
||||
|
@ -26,6 +28,7 @@ export const SimpleSettings: FC<Props> = ({
|
|||
createIndexPattern,
|
||||
onCreateIndexPatternChange,
|
||||
indexNameError,
|
||||
combinedFields,
|
||||
}) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
|
@ -75,6 +78,10 @@ export const SimpleSettings: FC<Props> = ({
|
|||
onChange={onCreateIndexPatternChange}
|
||||
data-test-subj="mlFileDataVisCreateIndexPatternCheckbox"
|
||||
/>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<CombinedFieldsReadOnlyForm combinedFields={combinedFields} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -26,6 +26,11 @@ import { ImportProgress, IMPORT_STATUS } from '../import_progress';
|
|||
import { ImportErrors } from '../import_errors';
|
||||
import { ImportSummary } from '../import_summary';
|
||||
import { ImportSettings } from '../import_settings';
|
||||
import {
|
||||
addCombinedFieldsToPipeline,
|
||||
addCombinedFieldsToMappings,
|
||||
getDefaultCombinedFields,
|
||||
} from '../combined_fields';
|
||||
import { ExperimentalBadge } from '../experimental_badge';
|
||||
import { getIndexPatternNames, loadIndexPatterns } from '../../../../util/index_utils';
|
||||
import { ml } from '../../../../services/ml_api_service';
|
||||
|
@ -68,6 +73,7 @@ const DEFAULT_STATE = {
|
|||
timeFieldName: undefined,
|
||||
isFilebeatFlyoutVisible: false,
|
||||
checkingValidIndex: false,
|
||||
combinedFields: [],
|
||||
};
|
||||
|
||||
export class ImportView extends Component {
|
||||
|
@ -386,6 +392,10 @@ export class ImportView extends Component {
|
|||
});
|
||||
};
|
||||
|
||||
onCombinedFieldsChange = (combinedFields) => {
|
||||
this.setState({ combinedFields });
|
||||
};
|
||||
|
||||
setImportProgress = (progress) => {
|
||||
this.setState({
|
||||
uploadProgress: progress,
|
||||
|
@ -444,6 +454,7 @@ export class ImportView extends Component {
|
|||
timeFieldName,
|
||||
isFilebeatFlyoutVisible,
|
||||
checkingValidIndex,
|
||||
combinedFields,
|
||||
} = this.state;
|
||||
|
||||
const createPipeline = pipelineString !== '';
|
||||
|
@ -513,6 +524,9 @@ export class ImportView extends Component {
|
|||
onPipelineStringChange={this.onPipelineStringChange}
|
||||
indexNameError={indexNameError}
|
||||
indexPatternNameError={indexPatternNameError}
|
||||
combinedFields={combinedFields}
|
||||
onCombinedFieldsChange={this.onCombinedFieldsChange}
|
||||
results={this.props.results}
|
||||
/>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
@ -644,12 +658,22 @@ function getDefaultState(state, results) {
|
|||
? JSON.stringify(DEFAULT_INDEX_SETTINGS, null, 2)
|
||||
: state.indexSettingsString;
|
||||
|
||||
const combinedFields = state.combinedFields.length
|
||||
? state.combinedFields
|
||||
: getDefaultCombinedFields(results);
|
||||
|
||||
const mappingsString =
|
||||
state.mappingsString === '' ? JSON.stringify(results.mappings, null, 2) : state.mappingsString;
|
||||
state.mappingsString === ''
|
||||
? JSON.stringify(addCombinedFieldsToMappings(results.mappings, combinedFields), null, 2)
|
||||
: state.mappingsString;
|
||||
|
||||
const pipelineString =
|
||||
state.pipelineString === '' && results.ingest_pipeline !== undefined
|
||||
? JSON.stringify(results.ingest_pipeline, null, 2)
|
||||
? JSON.stringify(
|
||||
addCombinedFieldsToPipeline(results.ingest_pipeline, combinedFields),
|
||||
null,
|
||||
2
|
||||
)
|
||||
: state.pipelineString;
|
||||
|
||||
const timeFieldName = results.timestamp_field;
|
||||
|
@ -660,6 +684,7 @@ function getDefaultState(state, results) {
|
|||
mappingsString,
|
||||
pipelineString,
|
||||
timeFieldName,
|
||||
combinedFields,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue